* Enhanced Editor (#100)

* refactor: editor page to improve responsiveness

* feat: add editor tab persistance to url

* chore: clean up classNames

* feat: enhance theme color-preview to make it responsive

* feat: add ai chat tab working with local state

* successful responses return a theme color-preview
* error responses return a generic message

* fix: invalid tabs fallback to default tab "colors"

* fix: remove messages scroll animation on tabs change and remove duplicate preview

* fix: minor fixes in classNames

* feat: Chat starts with a default message

* refactor: Enhance editor controls and toolbar buttons to disable when generating theme

- Updated button components to be extendible.
- Refactored buttons to utilize TooltipWrapper for consistent tooltip behavior.

* fix: sync controls tabs by 'value' instead of 'defaultValue'

* fix: load messages from local storage, otherwise set a default initial message

* fix: editor mention list positions correctly and add a tab handler for selecting mention

* fix: build errors due to unused imports

* chore: improve defaults and styles for ai chat interface and editor

* refactor: move AIChat state management to a zustand store

* feat: preserve control tab between edit mode and traditional editor mode

* feat: reset ai chat on editor reset button click

* feat: load chat messages dynamically to avoid layout shifts due to local storage

* fix: Use const id for default ai message to avoid re-render

* fix: Use searchParams for callback url as well after auth

* refactor: Remove unused getEditorConfig code

* feat: Only have 1 handler per postlogin action type

This was causing issues in AI Chat form. AI_GENERATE_FROM_CHAT action
was being scheduled twice because of re-render (caused by lazy-loaded
component)

* fix: Add user message for post login action

* refactor: Only use JSONContent in application state (derive textContent from it)

* feat: Auto-attach current theme to prompt after 1st user message

* feat: clean up AI interface and add community themes section

* feat: AI entrypoint redirect to main Editor and trigger theme generation

* chore: improve styles on ai page

* refactor: clean up AI components components from AI page and Editor page

* refactor: share repeated theme generation logic

* fix: chat messages inconsistent padding and pill buttons disabled state

* fix: build error

* feat: remove AI Generation Dialog and related logic

* feat: show agent response about the theme

* refactor: simplify theme generation logic and improve error handling

* refactor: transition from JSONContent to AIPromptData for theme generation

* feat: add retry functionality for user messages in AI chat interface

* refactor: remove unused loading fallback component and integrate loading indicator in chat messages

* change position of retry button

* feat: Improve system prompt

* refactor: replace createCurrentThemePrompt with attachCurrentThemeMention for improved prompt handling

* feat: add a placeholder for new chat and fix chat area styles

* chore: hide Community themes from AI page

* fix: build errors

* fix: ai theme generation only happens when there is content to be sent as the prompt

* feat: show AI tab in production

* fix: Early return after showing error message in ai chat

* feat: Attach last generated message instead of current theme for chat

* Use @ mentions in example prompts

* Enhance mention rendering in AI prompts and update mention styles in CSS

* skip posthog on dev

* Enhanced Editor (#100)

* refactor: editor page to improve responsiveness

* feat: add editor tab persistance to url

* chore: clean up classNames

* feat: enhance theme color-preview to make it responsive

* feat: add ai chat tab working with local state

* successful responses return a theme color-preview
* error responses return a generic message

* fix: invalid tabs fallback to default tab "colors"

* fix: remove messages scroll animation on tabs change and remove duplicate preview

* fix: minor fixes in classNames

* feat: Chat starts with a default message

* refactor: Enhance editor controls and toolbar buttons to disable when generating theme

- Updated button components to be extendible.
- Refactored buttons to utilize TooltipWrapper for consistent tooltip behavior.

* fix: sync controls tabs by 'value' instead of 'defaultValue'

* fix: load messages from local storage, otherwise set a default initial message

* fix: editor mention list positions correctly and add a tab handler for selecting mention

* fix: build errors due to unused imports

* chore: improve defaults and styles for ai chat interface and editor

* refactor: move AIChat state management to a zustand store

* feat: preserve control tab between edit mode and traditional editor mode

* feat: reset ai chat on editor reset button click

* feat: load chat messages dynamically to avoid layout shifts due to local storage

* fix: Use const id for default ai message to avoid re-render

* fix: Use searchParams for callback url as well after auth

* refactor: Remove unused getEditorConfig code

* feat: Only have 1 handler per postlogin action type

This was causing issues in AI Chat form. AI_GENERATE_FROM_CHAT action
was being scheduled twice because of re-render (caused by lazy-loaded
component)

* fix: Add user message for post login action

* refactor: Only use JSONContent in application state (derive textContent from it)

* feat: Auto-attach current theme to prompt after 1st user message

* feat: clean up AI interface and add community themes section

* feat: AI entrypoint redirect to main Editor and trigger theme generation

* chore: improve styles on ai page

* refactor: clean up AI components components from AI page and Editor page

* refactor: share repeated theme generation logic

* fix: chat messages inconsistent padding and pill buttons disabled state

* fix: build error

* feat: remove AI Generation Dialog and related logic

* feat: show agent response about the theme

* refactor: simplify theme generation logic and improve error handling

* refactor: transition from JSONContent to AIPromptData for theme generation

* feat: add retry functionality for user messages in AI chat interface

* refactor: remove unused loading fallback component and integrate loading indicator in chat messages

* change position of retry button

* feat: Improve system prompt

* refactor: replace createCurrentThemePrompt with attachCurrentThemeMention for improved prompt handling

* feat: add a placeholder for new chat and fix chat area styles

* chore: hide Community themes from AI page

* fix: build errors

* fix: ai theme generation only happens when there is content to be sent as the prompt

* feat: show AI tab in production

* fix: Early return after showing error message in ai chat

* feat: Attach last generated message instead of current theme for chat

* Use @ mentions in example prompts

* Enhance mention rendering in AI prompts and update mention styles in CSS

* fix: missing fade effect on chat messages scroll area and overflow in placeholder buttons

* feat: add user name from session to the chat placeholder component

* feat: Improve system prompt context

* chore: remove unnecessary context from example prompts

* feat: Enhance mention list with auto-scroll functionality and update prompt structures in no-messages placeholder

* feat: Add character limit for AI prompt input and integrate character count extension

* fix: Simplify prompt data handling in AI chat component and adjust textarea behavior during AI generation

* fix: Update theme state handling to include current mode in editor store

* chore: improve styles for consistency

* feat: add fun prompts to chat placeholder

* feat: add typography demo to preview panel

* feat: add responsive cards demo based on shadcn showcase

* fix: typography demo scroll area

* fix: build errors due to components refactor

* fix: wrap typography demo in ExamplesPreviewContainer

* feat: change default theme to match shadcn/ui default

* chore: move some theme previews into a dropdown to keep ui clean

---------

Co-authored-by: Luis Llanes <137589205+llanesluis@users.noreply.github.com>
Co-authored-by: Luis Llanes <luisllaboj@gmail.com>
This commit is contained in:
Sahaj Jain
2025-05-31 20:08:53 +05:30
committed by GitHub
co-authored by Luis Llanes Luis Llanes
parent 686b38eee1
commit 2548f49de1
93 changed files with 3991 additions and 2428 deletions
+10 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -29,10 +29,17 @@ export function AuthDialog({
trigger,
}: AuthDialogProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isSignIn, setIsSignIn] = useState(initialMode === "signin");
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const getCallbackUrl = () => {
const baseUrl = pathname || "/editor/theme";
const queryString = searchParams.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
};
useEffect(() => {
if (open) {
setIsSignIn(initialMode === "signin");
@@ -44,7 +51,7 @@ export function AuthDialog({
try {
await authClient.signIn.social({
provider: "google",
callbackURL: pathname || "/editor/theme",
callbackURL: getCallbackUrl(),
});
} catch (error) {
console.error("Google Sign In Error:", error);
@@ -57,7 +64,7 @@ export function AuthDialog({
try {
await authClient.signIn.social({
provider: "github",
callbackURL: pathname || "/editor/theme",
callbackURL: getCallbackUrl(),
});
} catch (error) {
console.error("GitHub Sign In Error:", error);
+22
View File
@@ -0,0 +1,22 @@
import { ArrowRight } from "lucide-react";
import Link from "next/link";
export function AIAnnouncement() {
return (
<div className="mx-auto max-w-3xl">
<Link
href={"/editor/theme?tab=ai"}
className="group bg-muted flex items-center justify-between gap-2 rounded-full px-2 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md"
>
<span className="bg-primary text-primary-foreground rounded-full px-1.5 py-0.5 text-xs font-medium capitalize">
Beta
</span>
<span className="text-muted-foreground group-hover:text-foreground text-sm font-medium transition-colors">
Try the new AI theme editor
</span>
<ArrowRight className="text-muted-foreground group-hover:text-foreground size-4 -rotate-45 transition-all group-hover:rotate-0" />
</Link>
</div>
);
}
+22 -74
View File
@@ -3,81 +3,50 @@
import ThemePresetSelect from "@/components/editor/theme-preset-select";
import { Loading } from "@/components/loading";
import { Button } from "@/components/ui/button";
import { useAIThemeGeneration, useAIThemeGenerationPrompts } from "@/hooks/use-ai-theme-generation";
import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { toast } from "@/hooks/use-toast";
import { authClient } from "@/lib/auth-client";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth-store";
import { JSONContent } from "@tiptap/react";
import { AIPromptData } from "@/types/ai";
import { ArrowUp, Loader, StopCircle } from "lucide-react";
import dynamic from "next/dynamic";
import { usePreviewPanel } from "../hooks/use-preview-panel";
import { LoadingLogo } from "./loading-logo";
import { useState } from "react";
const CustomTextarea = dynamic(() => import("@/components/editor/custom-textarea"), {
ssr: false,
loading: () => <Loading className="min-h-[60px] w-full rounded-lg" />,
});
export function AIChatForm() {
const { prompt, jsonPrompt, setPrompt, setJsonPrompt } = useAIThemeGenerationPrompts();
const {
generateTheme,
loading: aiGenerateLoading,
cancelThemeGeneration,
} = useAIThemeGeneration();
export function AIChatForm({
handleThemeGeneration,
}: {
handleThemeGeneration: (promptData: AIPromptData | null) => void;
}) {
const [promptData, setPromptData] = useState<AIPromptData | null>(null);
const { loading: aiGenerateLoading, cancelThemeGeneration } = useAIThemeGeneration();
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
const { setIsPreviewPanelOpen } = usePreviewPanel();
const handleSuccessfulThemeGeneration = () => {
setIsPreviewPanelOpen(true);
// TODO: reset prompts
};
usePostLoginAction("AI_GENERATE_FROM_CHAT", ({ prompt, jsonPrompt }) => {
if (!prompt || !jsonPrompt) {
toast({
title: "Error",
description: "Failed to generate theme. Please try again.",
});
return;
}
generateTheme({
prompt,
jsonPrompt,
onSuccess: handleSuccessfulThemeGeneration,
});
});
const handleContentChange = (textContent: string, jsonContent: JSONContent) => {
setJsonPrompt(JSON.stringify(jsonContent));
setPrompt(textContent);
const handleContentChange = (content: AIPromptData) => {
setPromptData(content);
};
const handleGenerate = async () => {
if (!session) {
openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", { prompt, jsonPrompt });
return;
}
await generateTheme({ prompt, jsonPrompt, onSuccess: handleSuccessfulThemeGeneration });
if (!promptData?.content) return;
handleThemeGeneration(promptData);
};
return (
<div className="@container/form relative transition-all">
<div className="bg-background relative z-10 flex size-full min-h-[100px] flex-1 flex-col overflow-hidden rounded-lg border shadow-xs">
<label className="sr-only">Chat Input</label>
<div className={cn("min-h-[80px] p-2 pb-0", aiGenerateLoading && "pointer-events-none")}>
<div className={cn("min-h-[60px] p-2 pb-0", aiGenerateLoading && "pointer-events-none")}>
<div
className="bg-muted/40 relative isolate rounded-lg"
aria-disabled={aiGenerateLoading}
>
<AIChatFormGeneratingFallback aiGenerateLoading={aiGenerateLoading} />
<CustomTextarea onContentChange={handleContentChange} onGenerate={handleGenerate} />
<CustomTextarea
onContentChange={handleContentChange}
onGenerate={handleGenerate}
characterLimit={AI_PROMPT_CHARACTER_LIMIT}
/>
</div>
</div>
@@ -87,7 +56,6 @@ export function AIChatForm() {
</div>
<div className="flex items-center gap-2">
{/* TODO: Add image upload */}
{aiGenerateLoading ? (
<Button
variant="destructive"
@@ -103,7 +71,7 @@ export function AIChatForm() {
size="icon"
className="size-8"
onClick={handleGenerate}
disabled={!prompt || aiGenerateLoading}
disabled={!promptData?.content || aiGenerateLoading}
>
{aiGenerateLoading ? <Loader className="animate-spin" /> : <ArrowUp />}
</Button>
@@ -114,23 +82,3 @@ export function AIChatForm() {
</div>
);
}
function AIChatFormGeneratingFallback({ aiGenerateLoading }: { aiGenerateLoading: boolean }) {
return (
<div
className={cn(
"bg-background/50 pointer-events-none absolute inset-0 z-1 flex size-full items-center justify-center opacity-0 backdrop-blur-sm transition-all duration-150 ease-out",
aiGenerateLoading && "pointer-events-auto opacity-100"
)}
>
<div
className={cn(
"text-muted-foreground size-8 scale-0 opacity-0 transition-all duration-150 ease-out md:size-10",
aiGenerateLoading && "scale-100 opacity-100"
)}
>
<LoadingLogo />
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { toast } from "@/hooks/use-toast";
import { authClient } from "@/lib/auth-client";
import { useAIChatStore } from "@/store/ai-chat-store";
import { useAuthStore } from "@/store/auth-store";
import { AIPromptData } from "@/types/ai";
import { useRouter } from "next/navigation";
import { AIChatForm } from "./ai-chat-form";
import { ChatHeading } from "./chat-heading";
import { SuggestedPillActions } from "./suggested-pill-actions";
import { buildPrompt } from "@/lib/ai-theme-generator";
export function AIChatHero() {
const { addUserMessage, addAssistantMessage, clearMessages } = useAIChatStore();
const router = useRouter();
const { generateTheme } = useAIThemeGeneration();
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
const handleRedirectAndThemeGeneration = async (promptData: AIPromptData | null) => {
if (!session) {
if (promptData) {
openAuthDialog("signup", "AI_GENERATE_FROM_PAGE", { promptData });
}
return;
}
if (!promptData) {
toast({
title: "Error",
description: "Failed to generate theme. Please try again.",
});
return;
}
// Clear the messages when the user starts a chat from the '/ai' page
clearMessages();
addUserMessage({
promptData,
});
router.push("/editor/theme?tab=ai");
const result = await generateTheme(buildPrompt(promptData));
if (!result) {
addAssistantMessage({
content: "Failed to generate theme.",
});
return;
}
addAssistantMessage({
content:
result?.text ??
(result?.theme ? "Here's the theme I generated for you." : "Failed to generate theme."),
themeStyles: result?.theme,
});
};
usePostLoginAction("AI_GENERATE_FROM_PAGE", ({ promptData }) => {
if (!promptData) {
toast({
title: "Error",
description: "Failed to generate theme. Please try again.",
});
return;
}
handleRedirectAndThemeGeneration(promptData);
});
return (
<div className="relative isolate flex w-full flex-1 overflow-hidden">
<div className="@container relative isolate z-1 mx-auto flex max-w-[49rem] flex-1 flex-col justify-center px-4">
<ChatHeading />
{/* Chat form input and suggestions */}
<div className="relative mx-auto flex w-full flex-col">
<div className="relative isolate z-10 w-full">
<AIChatForm handleThemeGeneration={handleRedirectAndThemeGeneration} />
</div>
{/* Quick suggestions */}
<HorizontalScrollArea className="mx-auto pt-4 pb-2">
<SuggestedPillActions handleThemeGeneration={handleRedirectAndThemeGeneration} />
</HorizontalScrollArea>
</div>
</div>
</div>
);
}
-133
View File
@@ -1,133 +0,0 @@
"use client";
import ThemePreviewPanel from "@/components/editor/theme-preview-panel";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { useTheme } from "@/components/theme-provider";
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { useAIThemeGenerationResult } from "@/hooks/use-ai-theme-generation";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { useEffect, useRef, useState } from "react";
import { ImperativePanelGroupHandle } from "react-resizable-panels";
import { usePreviewPanel } from "../hooks/use-preview-panel";
import { AIChatForm } from "./ai-chat-form";
import { ChatHeading } from "./chat-heading";
import { ClosableSuggestedPillActions, SuggestedPillActions } from "./suggested-pill-actions";
export function AIInterface() {
const isMobile = useIsMobile();
const { themeState } = useEditorStore();
const { theme: mode } = useTheme();
const { hasPrompted } = useAIThemeGenerationResult();
// Resizable panel
const { isPreviewPanelOpen, setIsPreviewPanelOpen } = usePreviewPanel();
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const [lastOpenLayout, setLastOpenLayout] = useState<number[]>([30, 70]);
useEffect(() => {
const panelGroup = panelGroupRef.current;
if (!panelGroup) return;
if (isMobile) {
return;
}
if (isPreviewPanelOpen) {
panelGroup.setLayout(lastOpenLayout); // Restore last open layout
} else {
// Before closing, capture the current layout if it was open
const currentLayout = panelGroup.getLayout();
if (currentLayout[1] > 1) {
// If preview panel had some size
setLastOpenLayout(currentLayout);
}
panelGroup.setLayout([100, 0]); // Collapse preview panel
}
}, [isPreviewPanelOpen, lastOpenLayout, isMobile]);
return (
<div className="relative isolate flex flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal" ref={panelGroupRef} className="isolate">
{/* Chat section */}
<ResizablePanel
defaultSize={isPreviewPanelOpen ? lastOpenLayout[0] : 100}
minSize={30}
className="z-1 min-w-[max(30%,22rem)]"
>
<section className="@container relative isolate z-1 mx-auto flex h-full max-w-[49rem] flex-col justify-center px-4">
<ChatHeading />
{/* TODO: Chat messages */}
{/* Chat form input and suggestions */}
<div className="relative mx-auto flex w-full flex-col">
<div className="relative isolate z-10 w-full">
{hasPrompted && <ClosableSuggestedPillActions />}
<AIChatForm />
{/* Background gradients */}
<div
className={cn(
"animate-in fade-in pointer-events-none absolute inset-0 z-[-1] bg-[radial-gradient(ellipse_at_10%_70%,var(--secondary),transparent_15%)] blur-2xl duration-500",
hasPrompted ? "opacity-0" : "opacity-100 dark:opacity-50"
)}
/>
<div
className={cn(
"animate-in fade-in pointer-events-none absolute inset-0 z-[-1] bg-[radial-gradient(ellipse_at_90%_30%,var(--primary),transparent_15%)] blur-2xl duration-500",
hasPrompted ? "opacity-0" : "opacity-100 dark:opacity-50"
)}
/>
</div>
{/* Quick suggestions */}
{!hasPrompted && (
<HorizontalScrollArea className="mx-auto pt-4 pb-2">
<SuggestedPillActions />
</HorizontalScrollArea>
)}
</div>
<p className="text-muted-foreground truncate py-2 text-center text-xs tracking-tight">
tweakcn may make mistakes. Please use with discretion.
</p>
</section>
</ResizablePanel>
{!isMobile && isPreviewPanelOpen && (
<ResizableHandle className="after:hover:bg-muted z-1" />
)}
{/* Preview section */}
{!isMobile && (
<ResizablePanel
defaultSize={isPreviewPanelOpen ? lastOpenLayout[1] : 0}
maxSize={70}
minSize={isPreviewPanelOpen ? 40 : 0}
className="z-2"
>
<section
className={cn("isolate z-2 flex h-full flex-col overflow-hidden max-md:hidden")}
>
<ThemePreviewPanel styles={themeState.styles} currentMode={mode} />
</section>
</ResizablePanel>
)}
</ResizablePanelGroup>
<Drawer open={isMobile && isPreviewPanelOpen} onOpenChange={setIsPreviewPanelOpen}>
<DrawerContent className="flex max-h-[calc(90svh)] md:hidden">
<DrawerTitle className="sr-only">Preview Panel</DrawerTitle>
<section className="isolate flex flex-col overflow-hidden">
{isMobile && <ThemePreviewPanel styles={themeState.styles} currentMode={mode} />}
</section>
</DrawerContent>
</Drawer>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import Logo from "@/assets/logo.svg";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { Theme, ThemePreset } from "@/types/theme";
import { Moon, MoreVertical, Sun } from "lucide-react";
// This is repeating from `dashboard/components/theme-card.tsx`
type SwatchDefinition = {
name: string; // Text to display on hover
bgKey: keyof Theme["styles"]["light" | "dark"]; // Key for background color
fgKey: keyof Theme["styles"]["light" | "dark"]; // Key for text color
};
// This is repeating from `dashboard/components/theme-card.tsx`
const swatchDefinitions: SwatchDefinition[] = [
{ name: "Primary", bgKey: "primary", fgKey: "primary-foreground" },
{ name: "Secondary", bgKey: "secondary", fgKey: "secondary-foreground" },
{ name: "Accent", bgKey: "accent", fgKey: "accent-foreground" },
{ name: "Muted", bgKey: "muted", fgKey: "muted-foreground" },
// Special case: Background swatch shows "Foreground" text using the main foreground color
{ name: "Background", bgKey: "background", fgKey: "foreground" },
];
export function CommunityThemeCard({ themePreset }: { themePreset: ThemePreset }) {
const { themeState } = useEditorStore();
const mode = themeState.currentMode;
return (
<div className="group/card relative flex flex-col gap-2">
<div className="group/preview relative flex h-56 overflow-hidden rounded-lg border">
<div className="from-foreground/20 to-foreground-10 absolute inset-0 z-1 flex flex-col items-center justify-center gap-2 bg-gradient-to-b opacity-0 transition-opacity duration-300 ease-in-out group-hover/preview:opacity-100">
<Button variant="outline" size="sm" className="w-28 drop-shadow">
View Details
</Button>
<Button size="sm" className="w-28 drop-shadow">
View in Editor
</Button>
</div>
{/* TEMPORARY CARD IMPLEMENTATION: Based on `dashboard/components/theme-card.tsx` */}
<div className="relative isolate flex flex-1 flex-col">
{/* Light mode swatches */}
<div className="group relative flex flex-1">
<div className="absolute top-2 left-2 z-10 size-6">
<Sun className="size-full opacity-80 drop-shadow-2xl transition-opacity group-hover/preview:opacity-100" />
</div>
{swatchDefinitions.map((swatch) => (
<div
key={swatch.name + swatch.bgKey + "light"}
className={cn(
"group/swatch relative h-full flex-1 transition-all duration-300 ease-in-out"
)}
style={{ backgroundColor: themePreset.styles.light[swatch.bgKey] }}
></div>
))}
</div>
{/* Dark mode swatches */}
<div className="group relative flex flex-1">
<div className="absolute right-2 bottom-2 z-10 size-6">
<Moon className="size-full opacity-80 drop-shadow-2xl transition-opacity group-hover/preview:opacity-100" />
</div>
{swatchDefinitions.map((swatch) => (
<div
key={swatch.name + swatch.bgKey + "dark"}
className={cn(
"group/swatch relative h-full flex-1 transition-all duration-300 ease-in-out"
)}
style={{ backgroundColor: themePreset.styles.dark[swatch.bgKey] }}
></div>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div
className="from-primary to-secondary aspect-square size-10 rounded-full border bg-linear-150/oklch"
style={
{
"--primary": themePreset.styles[mode].primary,
"--primary-foreground": themePreset.styles[mode]["primary-foreground"],
"--secondary": themePreset.styles[mode].secondary,
} as React.CSSProperties
}
>
<Logo className="text-primary-foreground size-full p-2 drop-shadow-lg" />
</div>
<div className="flex flex-col gap-1">
<h3 className="line-clamp-1 text-sm leading-none font-medium">{themePreset.label}</h3>
<p className="text-muted-foreground line-clamp-1 text-xs">
{themePreset.createdAt ?? "Unknown creation date"}
</p>
</div>
<Button variant="ghost" size="icon" className="ml-auto">
<MoreVertical />
</Button>
</div>
</div>
);
}
export function CommunityThemeCardSkeleton() {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-56 w-full rounded-lg" />
<div className="flex items-center gap-2">
<Skeleton className="aspect-square size-10 rounded-full" />
<div className="flex w-full flex-col gap-1.5">
<Skeleton className="line-clamp-1 h-4 w-1/2 rounded-lg leading-none font-medium" />
<Skeleton className="text-muted-foreground h-3 w-1/3 rounded-lg" />
</div>
<MoreVertical className="text-muted ml-auto animate-pulse" />
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import { ChevronRight } from "lucide-react";
import { Suspense } from "react";
import { CommunityThemeCard, CommunityThemeCardSkeleton } from "./community-theme-card";
import { ThemePreset } from "@/types/theme";
import { defaultPresets } from "@/utils/theme-presets";
// TODO: Remove this once we have a real API to fetch the community themes
const getDefaultThemePresets = async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return defaultPresets;
};
export async function CommunityThemes() {
const themePresetsPromise = getDefaultThemePresets();
return (
<>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<h2 className="font-semibold">From the Community</h2>
<Button variant="link" className="h-fit gap-1 p-0 [&>svg]:size-3">
View All <ChevronRight />
</Button>
</div>
<p className="text-muted-foreground text-sm">
Explore the themes the community is creating with tweakcn.
</p>
</div>
<div className="grid grid-cols-1 justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Suspense
fallback={
<>
<CommunityThemeCardSkeleton />
<CommunityThemeCardSkeleton />
<CommunityThemeCardSkeleton />
</>
}
>
<CommunityThemeCards themePresetsPromise={themePresetsPromise} />
</Suspense>
</div>
</>
);
}
interface CommunityThemeCardsProps {
themePresetsPromise: Promise<Record<string, ThemePreset>>;
}
export async function CommunityThemeCards({ themePresetsPromise }: CommunityThemeCardsProps) {
const themePresets = await themePresetsPromise;
const presets = Object.entries(themePresets).reduce(
(acc, [id, preset]) => {
acc[id] = {
label: preset.label,
styles: preset.styles,
};
return acc;
},
{} as Record<string, ThemePreset>
);
return (
<>
{Object.values(presets).map((preset) => (
<CommunityThemeCard key={preset.label} themePreset={preset} />
))}
</>
);
}
+19 -93
View File
@@ -1,109 +1,35 @@
"use client";
import { Button } from "@/components/ui/button";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth-store";
import { createCurrentThemePromptJson, PROMPTS } from "@/utils/prompts";
import { Sparkles, X } from "lucide-react";
import { ComponentProps, useState } from "react";
import { usePreviewPanel } from "../hooks/use-preview-panel";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { PROMPTS } from "@/utils/prompts";
import { createCurrentThemePrompt } from "@/utils/ai-prompt";
import { AIPromptData } from "@/types/ai";
import { Sparkles } from "lucide-react";
import { AIPillActionButton } from "@/components/editor/ai/ai-pill-action-button";
export function ClosableSuggestedPillActions() {
const [hasClosedSuggestions, setHasClosedSuggestions] = useState(false);
if (hasClosedSuggestions) return null;
return (
<div className="relative flex flex-col items-center justify-center">
{/* Fade out effect when scrolling */}
<div className="via-background/50 from-background pointer-events-none absolute -top-8 right-4 left-0 z-20 h-8 bg-gradient-to-t to-transparent opacity-100 transition-opacity ease-out" />
<div className="flex w-full items-center justify-between gap-4">
<h3 className="text-muted-foreground text-xs">Suggestions</h3>
<Button
variant="ghost"
size="icon"
className="size-6 [&>svg]:size-3"
onClick={() => setHasClosedSuggestions(true)}
>
<X />
</Button>
</div>
<HorizontalScrollArea className="py-2">
<SuggestedPillActions />
</HorizontalScrollArea>
</div>
);
}
export function SuggestedPillActions() {
const { generateTheme } = useAIThemeGeneration();
const { setIsPreviewPanelOpen } = usePreviewPanel();
const { openAuthDialog } = useAuthStore();
const { data: session } = authClient.useSession();
export function SuggestedPillActions({
handleThemeGeneration,
}: {
handleThemeGeneration: (promptData: AIPromptData | null) => void;
}) {
const { loading: aiIsGenerating } = useAIThemeGeneration();
const handleSetPrompt = async (prompt: string) => {
const jsonPrompt = createCurrentThemePromptJson({ prompt });
const stringifiedJsonPrompt = JSON.stringify(jsonPrompt);
if (!session) {
openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", {
prompt,
jsonPrompt: stringifiedJsonPrompt,
});
return;
}
await generateTheme({
prompt,
jsonPrompt: stringifiedJsonPrompt,
onSuccess: () => {
setIsPreviewPanelOpen(true);
},
});
const promptData = createCurrentThemePrompt({ prompt });
handleThemeGeneration(promptData);
};
return (
<>
{Object.entries(PROMPTS).map(([key, { label, prompt }]) => (
<PillButton key={key} onClick={() => handleSetPrompt(prompt)}>
<AIPillActionButton
key={key}
onClick={() => handleSetPrompt(prompt)}
disabled={aiIsGenerating}
>
<Sparkles /> {label}
</PillButton>
</AIPillActionButton>
))}
</>
);
}
interface PillButtonProps extends ComponentProps<typeof Button> {}
function PillButton({ className, children, ...props }: PillButtonProps) {
const { loading: aiGenerateLoading } = useAIThemeGeneration();
return (
<div
className={cn(
"group/pill relative active:scale-95",
aiGenerateLoading && "pointer-events-none"
)}
>
<div className="from-primary to-background absolute inset-0 z-[-1] rounded-full bg-gradient-to-br opacity-0 transition-all duration-150 ease-in group-hover/pill:opacity-30" />
<Button
variant="ghost"
size="sm"
className={cn(
"hover:bg-muted/50 text-muted-foreground hover:text-foreground border-border/80! rounded-full border bg-transparent px-2 font-medium text-nowrap whitespace-nowrap backdrop-blur-md transition-all duration-150 ease-in select-none focus:outline-none",
"group-hover/pill:inset-shadow-primary/50 h-7 inset-shadow-2xs inset-shadow-transparent [&>svg]:size-3",
className
)}
{...props}
disabled={aiGenerateLoading}
>
{children}
</Button>
</div>
);
}
@@ -1,45 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Eye, EyeOff } from "lucide-react";
import { usePreviewPanel } from "../hooks/use-preview-panel";
interface TogglePreviewPanelButtonProps extends React.ComponentProps<typeof Button> {}
export function TogglePreviewPanelButton({
className,
onClick,
...props
}: TogglePreviewPanelButtonProps) {
const { isPreviewPanelOpen, togglePreviewPanel } = usePreviewPanel();
const isMobile = useIsMobile();
if (isMobile && !isPreviewPanelOpen) {
return (
<Button
className={cn(className)}
variant="ghost"
size="icon"
onClick={togglePreviewPanel}
{...props}
>
<Eye />
</Button>
);
}
return (
<Button
className={cn(className)}
variant="ghost"
size="icon"
onClick={togglePreviewPanel}
{...props}
>
{!isPreviewPanelOpen ? <Eye /> : <EyeOff />}
</Button>
);
}
-158
View File
@@ -1,158 +0,0 @@
"use client";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { useTheme } from "@/components/theme-provider";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { DialogActionsProvider, useDialogActions } from "@/hooks/use-dialog-actions";
import { useIsMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/store/editor-store";
import { Code, Edit, Heart, Moon, Redo, RefreshCw, Sun, Undo } from "lucide-react";
import { useRouter } from "next/navigation";
import { usePreviewPanel } from "../hooks/use-preview-panel";
import { TogglePreviewPanelButton } from "./toggle-preview-panel-button";
export function Toolbar() {
return (
<DialogActionsProvider>
<div className="h-14 content-center overflow-hidden border-b">
<HorizontalScrollArea className="isolate mx-auto flex h-full w-full justify-center px-4 py-2">
<ToolbarActions />
</HorizontalScrollArea>
</div>
</DialogActionsProvider>
);
}
function ToolbarActions() {
const { resetToCurrentPreset, hasUnsavedChanges, redo, undo, canRedo, canUndo } =
useEditorStore();
const isMobile = useIsMobile();
const router = useRouter();
const { theme, toggleTheme } = useTheme();
const { loading: aiGenerateLoading } = useAIThemeGeneration();
const { isPreviewPanelOpen } = usePreviewPanel();
const { handleSaveClick, setCodePanelOpen } = useDialogActions();
const handleOpenInEditor = () => {
router.push("/editor/theme");
};
const handleThemeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const { clientX: x, clientY: y } = event;
toggleTheme({ x, y });
};
const handleOpenCodePanel = () => {
setCodePanelOpen(true);
};
const handleOpenSaveDialog = () => {
handleSaveClick({ shareAfterSave: true });
};
return (
<div className="flex items-center gap-1.5">
{/* Open in Editor Button */}
<TooltipWrapper label="Customize further in Editor" asChild>
<Button
size={isMobile ? "icon" : "default"}
variant="secondary"
className="border shadow-none"
onClick={handleOpenInEditor}
disabled={aiGenerateLoading}
>
<Edit />
<span className="hidden md:block">Open in Editor</span>
</Button>
</TooltipWrapper>
{/* Theme Toggle Button */}
<TooltipWrapper label="Toggle theme" asChild>
<Button
size="icon"
variant="ghost"
className="bg-background border"
disabled={aiGenerateLoading}
onClick={handleThemeToggle}
>
{theme === "light" ? <Moon /> : <Sun />}
</Button>
</TooltipWrapper>
{/* Undo Button */}
<TooltipWrapper label="Undo" asChild>
<Button
variant="ghost"
size="icon"
className="bg-background border"
onClick={undo}
disabled={!canUndo() || aiGenerateLoading}
>
<Undo />
</Button>
</TooltipWrapper>
{/* Redo Button */}
<TooltipWrapper label="Redo" asChild>
<Button
variant="ghost"
size="icon"
className="bg-background border"
onClick={redo}
disabled={!canRedo() || aiGenerateLoading}
>
<Redo />
</Button>
</TooltipWrapper>
{/* Reset Button */}
<TooltipWrapper label="Reset to preset defaults" asChild>
<Button
variant="ghost"
size={isMobile ? "icon" : "default"}
className="bg-background border"
onClick={resetToCurrentPreset}
disabled={!hasUnsavedChanges() || aiGenerateLoading}
>
<RefreshCw /> <span className="hidden md:block">Reset</span>
</Button>
</TooltipWrapper>
{/* Save Button */}
<TooltipWrapper label="Save theme" asChild>
<Button
variant="ghost"
size={isMobile ? "icon" : "default"}
className="bg-background border"
onClick={handleOpenSaveDialog}
disabled={aiGenerateLoading}
>
<Heart /> <span className="hidden md:block">Save</span>
</Button>
</TooltipWrapper>
{/* Code Button */}
<TooltipWrapper label="View theme code" asChild>
<Button
variant="ghost"
size={isMobile ? "icon" : "default"}
className="bg-background border"
onClick={handleOpenCodePanel}
disabled={aiGenerateLoading}
>
<Code /> <span className="hidden md:block">Code</span>
</Button>
</TooltipWrapper>
<TooltipWrapper
label={isPreviewPanelOpen ? "Hide preview panel" : "Show preview panel"}
asChild
>
<TogglePreviewPanelButton variant="ghost" className="border" />
</TooltipWrapper>
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
"use client";
import { createContext, useContext, useState } from "react";
interface PreviewPanelContextType {
isPreviewPanelOpen: boolean;
togglePreviewPanel: () => void;
setIsPreviewPanelOpen: (value: boolean) => void;
}
const PreviewPanelContext = createContext<PreviewPanelContextType | undefined>(undefined);
export function PreviewPanelProvider({ children }: { children: React.ReactNode }) {
const [isPreviewPanelOpen, setIsPreviewPanelOpen] = useState(false);
const togglePreviewPanel = () => {
console.log("Toggling preview panel");
setIsPreviewPanelOpen((prev) => !prev);
};
return (
<PreviewPanelContext.Provider
value={{
isPreviewPanelOpen,
togglePreviewPanel,
setIsPreviewPanelOpen,
}}
>
{children}
</PreviewPanelContext.Provider>
);
}
export function usePreviewPanel() {
const context = useContext(PreviewPanelContext);
if (context === undefined) {
throw new Error("usePreviewPanel must be used within a PreviewPanelProvider");
}
return context;
}
+3 -5
View File
@@ -2,11 +2,9 @@ import { Header } from "@/components/header";
export default function AiLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative isolate flex h-svh flex-col">
<div className="z-1">
<Header />
</div>
<main className="isolate z-2 flex flex-1 flex-col overflow-hidden">{children}</main>
<div className="relative isolate flex min-h-svh flex-col">
<Header />
<main className="isolate flex flex-1 flex-col overflow-y-auto">{children}</main>
</div>
);
}
+10 -8
View File
@@ -1,8 +1,7 @@
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
import { Metadata } from "next";
import { AIInterface } from "./components/ai-interface";
import { Toolbar } from "./components/toolbar";
import { PreviewPanelProvider } from "./hooks/use-preview-panel";
import { AIAnnouncement } from "./components/ai-announcement";
import { AIChatHero } from "./components/ai-chat-hero";
export const metadata: Metadata = {
title: "AI Theme Editor for shadcn/ui — tweakcn",
@@ -15,12 +14,15 @@ export const metadata: Metadata = {
export default function AiPage() {
return (
<PreviewPanelProvider>
<Toolbar />
<>
<AuthDialogWrapper />
<div className="relative isolate flex flex-1 flex-col overflow-hidden">
<AIInterface />
<div className="relative isolate container mx-auto flex flex-1 flex-col gap-24 overflow-x-visible overflow-y-auto px-4 md:px-6">
{/* AI Chat entry point section */}
<section className="relative isolate flex flex-col gap-4 pt-28 lg:pt-44">
<AIAnnouncement />
<AIChatHero />
</section>
</div>
</PreviewPanelProvider>
</>
);
}
+60 -23
View File
@@ -1,16 +1,22 @@
import { createGroq } from "@ai-sdk/groq";
import { createFallback } from "ai-fallback";
import { generateObject } from "ai";
import { z } from "zod";
import { themeStylePropsSchema } from "@/types/theme";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
import { NextRequest } from "next/server";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { auth } from "@/lib/auth";
import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
import { themeStylePropsSchema } from "@/types/theme";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createGroq } from "@ai-sdk/groq";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import { generateObject } from "ai";
import { createFallback } from "ai-fallback";
import { NextRequest } from "next/server";
import { z } from "zod";
const requestSchema = z.object({
prompt: z.string().min(1),
prompt: z
.string()
.min(1)
.max(AI_PROMPT_CHARACTER_LIMIT + 4000, {
message: `Failed to generate theme. Input character limit exceeded.`,
}),
});
// Create a new schema based on themeStylePropsSchema excluding 'spacing'
@@ -19,9 +25,12 @@ const themeStylePropsWithoutSpacing = themeStylePropsSchema.omit({
});
// Define the main theme schema using the modified props schema
const themeSchemaWithoutSpacing = z.object({
light: themeStylePropsWithoutSpacing,
dark: themeStylePropsWithoutSpacing,
const responseSchema = z.object({
text: z.string().describe("A concise paragraph on the generated theme"),
theme: z.object({
light: themeStylePropsWithoutSpacing,
dark: themeStylePropsWithoutSpacing,
}),
});
// Create Rate limit - 5 requests per 60 seconds
@@ -65,10 +74,7 @@ export async function POST(req: NextRequest) {
});
const model = createFallback({
models: [
groq("llama-3.3-70b-versatile"),
google("gemini-2.0-flash-lite"),
],
models: [groq("llama-3.3-70b-versatile"), google("gemini-2.0-flash-lite")],
onError: (error, modelId) => {
console.error(`Error with model ${modelId}:`, error);
},
@@ -77,12 +83,34 @@ export async function POST(req: NextRequest) {
const { object: theme } = await generateObject({
model,
schema: themeSchemaWithoutSpacing,
system: `You are an AI generating Shadcn UI color themes.
Format: Use Hex values (#000000) ONLY for colors. Use shadow-opacity instead of using rgba for shadow-color.
Requirement: Ensure light/dark mode cohesion. If asked to change the theme's main color (e.g., "make it green"), adjust related colors (--accent, --secondary, --ring, --border) along with --primary to create a cohesive new palette.
Ensure sufficient contrast between foreground and background colors.`,
prompt: `Generate Shadcn theme. Input: ${prompt}`,
schema: responseSchema,
system: `# Role
You are tweakcn, an expert shadcn/ui theme generator.
# Token Groups
- **Brand**: primary, secondary, accent, ring
- **Surfaces**: background, card, popover, muted, sidebar
- **Typography**: font-sans, font-serif, font-mono
- **Contrast pairs**: Some colors have a -foreground counterpart for text, (e.g., primary/primary-foreground, secondary/secondary-foreground)
# Rules **IMPORTANT**
- Output JSON matching schema exactly
- Colors: HEX only (#RRGGBB), do NOT output rgba()
- Shadows: Shadow Opacity is handled separately (e.g., via \`--shadow-opacity\`);
- Generate harmonious light/dark modes
- Ensure contrast for base/foreground pairs
- Don't change typography unless requested
# Color Change Logic
- "Make it [color]" → modify brand colors only
- "Background darker/lighter" → modify surface colors only
- Specific tokens requests → change those tokens + their direct foreground pairs
- "Change [colors] in light/dark mode" → change those colors only in the requested mode, leave the other mode unchanged. (e.g. "Make primary color in light mode a little darker" → only change primary in light mode, keep dark mode unchanged)
- Maintain color harmony across all related tokens
# Text Description
Fill the \`text\` field in a friendly way, for example: "I've generated..." or "Alright, I've whipped up..."`,
prompt: `Create shadcn/ui theme for: ${prompt}`,
});
return new Response(JSON.stringify(theme), {
@@ -90,6 +118,15 @@ Ensure sufficient contrast between foreground and background colors.`,
});
} catch (error) {
console.error(error);
// Handle Zod validation errors specifically
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
return new Response(firstError.message, {
status: 400,
});
}
// Consider more specific error handling based on AI SDK errors if needed
return new Response("Error generating theme", { status: 500 });
}
+11 -20
View File
@@ -1,12 +1,11 @@
import { getEditorConfig } from "@/config/editors";
import { cn } from "@/lib/utils";
import Editor from "@/components/editor/editor";
import { Metadata } from "next";
import { Header } from "@/components/header";
import { getTheme } from "@/actions/themes";
import { Suspense } from "react";
import { Loading } from "@/components/loading";
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
import Editor from "@/components/editor/editor";
import { Header } from "@/components/header";
import { Loading } from "@/components/loading";
import { cn } from "@/lib/utils";
import { Metadata } from "next";
import { Suspense } from "react";
export const metadata: Metadata = {
title: "tweakcn — Theme Generator for shadcn/ui",
@@ -14,30 +13,22 @@ export const metadata: Metadata = {
"Easily customize and preview your shadcn/ui theme with tweakcn. Modify colors, fonts, and styles in real-time.",
};
export default async function Component({
params,
}: {
params: Promise<{ themeId: string[] }>;
}) {
export default async function Component({ params }: { params: Promise<{ themeId: string[] }> }) {
const { themeId } = await params;
const themePromise =
themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null);
const themePromise = themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null);
return (
<>
<div
className={cn(
"h-screen flex flex-col text-foreground bg-background transition-colors"
"text-foreground bg-background flex h-svh flex-col overflow-hidden transition-colors"
)}
>
<AuthDialogWrapper />
<Header />
<main className="flex-1 overflow-hidden">
<main className="flex flex-1 flex-col overflow-hidden">
<Suspense fallback={<Loading />}>
<Editor
config={getEditorConfig("theme")}
themePromise={themePromise}
/>
<Editor themePromise={themePromise} />
</Suspense>
</main>
</div>
+10 -2
View File
@@ -117,7 +117,7 @@ body {
/* Mention styles */
.mention {
@apply bg-primary/10 text-primary rounded-md px-1;
@apply bg-primary/10 rounded-md px-1 font-mono text-sm font-semibold;
}
@keyframes text {
@@ -133,7 +133,7 @@ body {
animation: text 3s linear infinite;
}
.scrollbar-thin {
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
@@ -141,3 +141,11 @@ body {
width: 8px;
}
}
@utility scrollbar-gutter-stable {
scrollbar-gutter: stable;
}
@utility scrollbar-gutter-both {
scrollbar-gutter: stable both-edges;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy, CopyCheck } from "lucide-react";
import { ComponentProps } from "react";
import { TooltipWrapper } from "./tooltip-wrapper";
import { Button } from "./ui/button";
interface CopyButtonProps extends ComponentProps<typeof Button> {
textToCopy: string;
successMessage?: {
title?: string;
description?: string;
};
}
export function CopyButton({ textToCopy, successMessage, className, ...props }: CopyButtonProps) {
const { copyToClipboard, hasCopied } = useCopyToClipboard();
return (
<TooltipWrapper label="Copy" asChild>
<Button
size="icon"
variant="ghost"
className={cn("size-6 [&>svg]:size-3.5", className)}
onClick={() => copyToClipboard(textToCopy, successMessage)}
{...props}
>
{hasCopied ? <CopyCheck /> : <Copy />}
<span className="sr-only">Copy</span>
</Button>
</TooltipWrapper>
);
}
+5 -11
View File
@@ -1,6 +1,7 @@
"use client";
import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { DialogActionsProvider, useDialogActions } from "@/hooks/use-dialog-actions";
export function ActionBar() {
@@ -12,27 +13,20 @@ export function ActionBar() {
}
function ActionBarContent() {
const {
isCreatingTheme,
handleSaveClick,
handleAiGenerateClick,
handleShareClick,
setCssImportOpen,
setCodePanelOpen,
} = useDialogActions();
const { isCreatingTheme, handleSaveClick, handleShareClick, setCssImportOpen, setCodePanelOpen } =
useDialogActions();
return (
<div className="border-b">
<div className="flex h-14 items-center justify-end gap-4 px-4">
<HorizontalScrollArea className="flex h-14 w-full items-center justify-end gap-4 px-4">
<ActionBarButtons
onImportClick={() => setCssImportOpen(true)}
onCodeClick={() => setCodePanelOpen(true)}
onSaveClick={() => handleSaveClick()}
onAiGenerateClick={handleAiGenerateClick}
isSaving={isCreatingTheme}
onShareClick={handleShareClick}
/>
</div>
</HorizontalScrollArea>
</div>
);
}
@@ -1,22 +1,22 @@
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import { ImportButton } from "./import-button";
import { ResetButton } from "./reset-button";
import { SaveButton } from "./save-button";
import { CodeButton } from "./code-button";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { CodeButton } from "./code-button";
import { EditButton } from "./edit-button";
import { ImportButton } from "./import-button";
import { MoreOptions } from "./more-options";
import { AIGenerateButton } from "./ai-generate-button";
import { ResetButton } from "./reset-button";
import { SaveButton } from "./save-button";
import { ShareButton } from "./share-button";
import { ThemeToggle } from "./theme-toggle";
import { UndoRedoButtons } from "./undo-redo-buttons";
import { useAIChatStore } from "@/store/ai-chat-store";
interface ActionBarButtonsProps {
onImportClick: () => void;
onCodeClick: () => void;
onSaveClick: () => void;
onAiGenerateClick: () => void;
onShareClick: (id?: string) => void;
isSaving: boolean;
}
@@ -25,34 +25,40 @@ export function ActionBarButtons({
onImportClick,
onCodeClick,
onSaveClick,
onAiGenerateClick,
onShareClick,
isSaving,
}: ActionBarButtonsProps) {
const { themeState, resetToCurrentPreset, hasUnsavedChanges } = useEditorStore();
const { loading: aiGenerationLoading } = useAIThemeGeneration();
const { getPreset } = useThemePresetStore();
const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined;
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
const { clearMessages } = useAIChatStore();
const handleReset = () => {
resetToCurrentPreset();
clearMessages();
};
return (
<div className="flex items-center gap-1">
<MoreOptions />
<MoreOptions disabled={aiGenerationLoading} />
<Separator orientation="vertical" className="mx-1 h-8" />
<ThemeToggle />
<Separator orientation="vertical" className="mx-1 h-8" />
<UndoRedoButtons />
<UndoRedoButtons disabled={aiGenerationLoading} />
<Separator orientation="vertical" className="mx-1 h-8" />
<ResetButton onClick={handleReset} disabled={!hasUnsavedChanges() || aiGenerationLoading} />
<div className="hidden items-center gap-1 md:flex">
<ResetButton onReset={resetToCurrentPreset} isDisabled={!hasUnsavedChanges()} />
<ImportButton onImportClick={onImportClick} />
<ImportButton onClick={onImportClick} disabled={aiGenerationLoading} />
</div>
<AIGenerateButton onClick={onAiGenerateClick} />
<Separator orientation="vertical" className="mx-1 h-8" />
{isSavedPreset && <EditButton themeId={themeState.preset as string} />}
<ShareButton onShareClick={() => onShareClick(themeState.preset)} />
<SaveButton onSaveClick={onSaveClick} isSaving={isSaving} />
<CodeButton onCodeClick={onCodeClick} />
{isSavedPreset && (
<EditButton themeId={themeState.preset as string} disabled={aiGenerationLoading} />
)}
<ShareButton onClick={() => onShareClick(themeState.preset)} disabled={aiGenerationLoading} />
<SaveButton onClick={onSaveClick} isSaving={isSaving} disabled={aiGenerationLoading} />
<CodeButton onClick={onCodeClick} disabled={aiGenerationLoading} />
</div>
);
}
@@ -1,115 +0,0 @@
"use client";
import { lazy, Suspense, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Sparkles, Loader2 } from "lucide-react";
import type { JSONContent } from "@tiptap/react";
import { Loading } from "@/components/loading";
const CustomTextarea = lazy(
() => import("@/components/editor/custom-textarea")
);
interface AIGenerateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
loading: boolean;
onGenerate: (textPrompt: string, jsonPrompt: string) => void;
}
export function AIGenerateDialog({
open,
onOpenChange,
loading,
onGenerate,
}: AIGenerateDialogProps) {
const [prompt, setPrompt] = useState("");
const [jsonPrompt, setJsonPrompt] = useState("");
const handleContentChange = (
textContent: string,
jsonContent: JSONContent
) => {
setJsonPrompt(JSON.stringify(jsonContent));
setPrompt(textContent);
};
const handleGenerate = () => {
onGenerate(prompt, jsonPrompt);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[550px] p-0 pt-6 overflow-hidden rounded-lg border shadow-lg gap-6">
<DialogHeader className="px-6">
<DialogTitle>How can I help you theme?</DialogTitle>
<DialogDescription>
Use <span className="font-medium">@</span> to mention a preset or
your current theme.
</DialogDescription>
</DialogHeader>
<div className="px-6">
<div className="bg-muted/40 rounded-lg p-1 max-w-[500px]">
<Suspense fallback={<Loading className="min-h-[60px]" />}>
<CustomTextarea
onContentChange={handleContentChange}
onGenerate={handleGenerate}
/>
</Suspense>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Try{" "}
<em className="text-foreground font-medium">
@Modern Minimal but in red
</em>{" "}
or{" "}
<em className="text-foreground font-medium">
Make the @Current Theme high contrast
</em>
</div>
</div>
<DialogFooter className="bg-muted/30 px-6 py-4 border-t">
<div className="flex items-center justify-end w-full gap-2">
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
disabled={loading}
size="sm"
>
Cancel
</Button>
<Button
onClick={handleGenerate}
disabled={!prompt.trim() || loading}
className="gap-1"
size="sm"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Create Theme
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,26 +1,25 @@
import { Braces } from "lucide-react";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Braces } from "lucide-react";
interface CodeButtonProps {
onCodeClick: () => void;
}
interface CodeButtonProps extends React.ComponentProps<typeof Button> {}
export function CodeButton({ onCodeClick }: CodeButtonProps) {
export function CodeButton({ className, ...props }: CodeButtonProps) {
return (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2"
onClick={onCodeClick}
>
<Braces className="size-3.5" />
<span className="hidden text-sm md:block">Code</span>
</Button>
</TooltipTrigger>
<TooltipContent>View theme code</TooltipContent>
</Tooltip>
<TooltipWrapper label="View theme code" asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
className
)}
{...props}
>
<Braces className="size-3.5" />
<span className="hidden text-sm md:block">Code</span>
</Button>
</TooltipWrapper>
);
}
@@ -1,32 +1,39 @@
import { PenLine } from "lucide-react";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { PenLine } from "lucide-react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
interface EditButtonProps {
interface EditButtonProps extends React.ComponentProps<typeof Button> {
themeId: string;
}
export function EditButton({ themeId }: EditButtonProps) {
export function EditButton({ themeId, disabled, className, ...props }: EditButtonProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const isEditing = pathname.includes(themeId);
// Keep the current search params for tab persistence
const href = `/editor/theme/${themeId}?${searchParams}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/editor/theme/${themeId}`}>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
<PenLine className="size-3.5" />
<span className="text-sm hidden md:block">Edit</span>
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>Edit theme</TooltipContent>
</Tooltip>
<TooltipWrapper label="Edit theme" asChild>
<Link href={href}>
<Button
variant="ghost"
size="sm"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
className
)}
disabled={disabled || isEditing}
{...props}
>
<PenLine className="size-3.5" />
<span className="hidden text-sm md:block">Edit</span>
</Button>
</Link>
</TooltipWrapper>
);
}
@@ -1,30 +1,25 @@
import { FileCode } from "lucide-react";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { FileCode } from "lucide-react";
interface ImportButtonProps {
onImportClick: () => void;
}
interface ImportButtonProps extends React.ComponentProps<typeof Button> {}
export function ImportButton({ onImportClick }: ImportButtonProps) {
export function ImportButton({ className, ...props }: ImportButtonProps) {
return (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
onClick={onImportClick}
>
<FileCode className="size-3.5" />
<span className="text-sm hidden md:block">Import</span>
</Button>
</TooltipTrigger>
<TooltipContent>Import CSS variables</TooltipContent>
</Tooltip>
<TooltipWrapper label="Import CSS variables" asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
className
)}
{...props}
>
<FileCode className="size-3.5" />
<span className="hidden text-sm md:block">Import</span>
</Button>
</TooltipWrapper>
);
}
@@ -1,25 +1,27 @@
import { useState } from "react";
import McpIcon from "@/assets/mcp.svg";
import ContrastChecker from "@/components/editor/contrast-checker";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreVertical } from "lucide-react";
import { MCPDialog } from "./mcp-dialog";
import McpIcon from "@/assets/mcp.svg";
import ContrastChecker from "@/components/editor/contrast-checker";
import { useEditorStore } from "@/store/editor-store";
import { MoreVertical } from "lucide-react";
import { useState } from "react";
import { MCPDialog } from "./mcp-dialog";
export function MoreOptions() {
interface MoreOptionsProps extends React.ComponentProps<typeof DropdownMenuTrigger> {}
export function MoreOptions({ ...props }: MoreOptionsProps) {
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const { themeState } = useEditorStore();
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger {...props} asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
@@ -30,9 +32,7 @@ export function MoreOptions() {
MCP
</DropdownMenuItem>
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<ContrastChecker
currentStyles={themeState.styles[themeState.currentMode]}
/>
<ContrastChecker currentStyles={themeState.styles[themeState.currentMode]} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,32 +1,25 @@
import { RefreshCw } from "lucide-react";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
interface ResetButtonProps {
onReset: () => void;
isDisabled: boolean;
}
interface ResetButtonProps extends React.ComponentProps<typeof Button> {}
export function ResetButton({ onReset, isDisabled }: ResetButtonProps) {
export function ResetButton({ className, ...props }: ResetButtonProps) {
return (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
onClick={onReset}
disabled={isDisabled}
>
<RefreshCw className="size-3.5" />
<span className="text-sm hidden md:block">Reset</span>
</Button>
</TooltipTrigger>
<TooltipContent>Reset to preset defaults</TooltipContent>
</Tooltip>
<TooltipWrapper label="Reset to preset defaults" asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
className
)}
{...props}
>
<RefreshCw className="size-3.5" />
<span className="hidden text-sm md:block">Reset</span>
</Button>
</TooltipWrapper>
);
}
@@ -1,32 +1,28 @@
import { Heart, Loader2 } from "lucide-react";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Heart, Loader2 } from "lucide-react";
interface SaveButtonProps {
onSaveClick: () => void;
interface SaveButtonProps extends React.ComponentProps<typeof Button> {
isSaving: boolean;
}
export function SaveButton({ onSaveClick, isSaving }: SaveButtonProps) {
export function SaveButton({ isSaving, disabled, className, ...props }: SaveButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2"
onClick={onSaveClick}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Heart className="size-3.5" />
)}
<span className="hidden text-sm md:block">Save</span>
</Button>
</TooltipTrigger>
<TooltipContent>Save theme</TooltipContent>
</Tooltip>
<TooltipWrapper label="Save theme" asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
className
)}
disabled={isSaving || disabled}
{...props}
>
{isSaving ? <Loader2 className="size-3.5 animate-spin" /> : <Heart className="size-3.5" />}
<span className="hidden text-sm md:block">Save</span>
</Button>
</TooltipWrapper>
);
}
@@ -1,36 +1,39 @@
import { Loader2, Share2 } from "lucide-react";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Loader2, Share2 } from "lucide-react";
interface ShareButtonProps {
onShareClick: () => void;
interface ShareButtonProps extends React.ComponentProps<typeof Button> {
isSharing?: boolean;
}
export function ShareButton({ onShareClick, isSharing }: ShareButtonProps) {
export function ShareButton({
onClick,
isSharing,
disabled,
className,
...props
}: ShareButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
onClick={onShareClick}
disabled={isSharing}
>
{isSharing ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Share2 className="size-3.5" />
)}
<span className="text-sm hidden md:block">Share</span>
</Button>
</TooltipTrigger>
<TooltipContent>Share theme</TooltipContent>
</Tooltip>
<TooltipWrapper label="Share theme" asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
className
)}
onClick={onClick}
disabled={isSharing || disabled}
{...props}
>
{isSharing ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Share2 className="size-3.5" />
)}
<span className="hidden text-sm md:block">Share</span>
</Button>
</TooltipWrapper>
);
}
@@ -1,35 +1,38 @@
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useEditorStore } from "@/store/editor-store";
import { Redo, Undo } from "lucide-react";
export function UndoRedoButtons() {
interface UndoRedoButtonsProps extends React.ComponentProps<typeof Button> {}
export function UndoRedoButtons({ disabled, ...props }: UndoRedoButtonsProps) {
const { undo, redo, canUndo, canRedo } = useEditorStore();
return (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={undo} disabled={!canUndo()}>
<Undo className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Undo</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={redo} disabled={!canRedo()}>
<Redo className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Redo</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<div className="flex items-center gap-1">
<TooltipWrapper label="Undo" asChild>
<Button
variant="ghost"
size="icon"
disabled={disabled || !canUndo()}
{...props}
onClick={undo}
>
<Undo className="h-4 w-4" />
</Button>
</TooltipWrapper>
<TooltipWrapper label="Redo" asChild>
<Button
variant="ghost"
size="icon"
disabled={disabled || !canRedo()}
{...props}
onClick={redo}
>
<Redo className="h-4 w-4" />
</Button>
</TooltipWrapper>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { Loading } from "@/components/loading";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { useAIChatStore } from "@/store/ai-chat-store";
import { AIPromptData } from "@/types/ai";
import { ArrowUp, Loader, Plus, StopCircle } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
const CustomTextarea = dynamic(() => import("@/components/editor/custom-textarea"), {
ssr: false,
loading: () => <Loading className="min-h-[60px] w-full rounded-lg" />,
});
export function AIChatForm({
handleThemeGeneration,
}: {
handleThemeGeneration: (promptData: AIPromptData | null) => void;
}) {
const [promptData, setPromptData] = useState<AIPromptData | null>(null);
const { loading: aiGenerateLoading, cancelThemeGeneration } = useAIThemeGeneration();
const { messages, clearMessages } = useAIChatStore();
const handleContentChange = (newPromptData: AIPromptData) => {
setPromptData(newPromptData);
};
const handleGenerate = async () => {
if (!promptData?.content) return;
handleThemeGeneration(promptData);
};
return (
<div className="@container/form relative transition-all">
<div className="bg-background relative z-10 flex size-full min-h-[100px] flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-xs">
<label className="sr-only">Chat Input</label>
<div className="min-h-[60px] p-2 pb-0">
<div className="bg-muted/40 relative isolate rounded-lg">
<CustomTextarea
onContentChange={handleContentChange}
onGenerate={handleGenerate}
key={messages.length}
characterLimit={AI_PROMPT_CHARACTER_LIMIT}
/>
</div>
</div>
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<TooltipWrapper label="Create new chat" asChild>
<Button
variant="outline"
size="sm"
onClick={clearMessages}
disabled={aiGenerateLoading || messages.length === 0}
className="shadow-none"
>
<Plus />
<span>New chat</span>
</Button>
</TooltipWrapper>
<div className="flex items-center gap-2">
{/* TODO: Add image upload */}
{aiGenerateLoading ? (
<TooltipWrapper label="Cancel generation" asChild>
<Button
variant="destructive"
size="sm"
onClick={cancelThemeGeneration}
className={cn("flex items-center gap-1 shadow-none", "@max-[350px]/form:w-8")}
>
<StopCircle />
<span className="hidden @[350px]/form:inline-flex">Stop</span>
</Button>
</TooltipWrapper>
) : (
<TooltipWrapper label="Send message" asChild>
<Button
size="icon"
className="size-8 shadow-none"
onClick={handleGenerate}
disabled={!promptData?.content || aiGenerateLoading}
>
{aiGenerateLoading ? <Loader className="animate-spin" /> : <ArrowUp />}
</Button>
</TooltipWrapper>
)}
</div>
</div>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { toast } from "@/components/ui/use-toast";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { buildPrompt } from "@/lib/ai-theme-generator";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useAIChatStore } from "@/store/ai-chat-store";
import { useAuthStore } from "@/store/auth-store";
import { AIPromptData } from "@/types/ai";
import { attachLastGeneratedThemeMention, mentionsCount } from "@/utils/ai-prompt";
import dynamic from "next/dynamic";
import { AIChatForm } from "./ai-chat-form";
import { ClosableSuggestedPillActions } from "./closeable-suggested-pill-actions";
const ChatMessages = dynamic(() => import("./chat-messages").then((mod) => mod.ChatMessages), {
ssr: false,
});
const NoMessagesPlaceholder = dynamic(
() => import("./no-messages-placeholder").then((mod) => mod.NoMessagesPlaceholder),
{
ssr: false,
}
);
export function AIInterface() {
const { generateTheme } = useAIThemeGeneration();
const { messages, addUserMessage, addAssistantMessage, resetMessagesUpToIndex } =
useAIChatStore();
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
const hasMessages = messages.length > 0;
const handleThemeGeneration = async (promptData: AIPromptData | null) => {
if (!session) {
openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", { promptData });
return;
}
if (!promptData) {
toast({
title: "Error",
description: "Failed to generate theme. Please try again.",
});
return;
}
let transformedPromptData = promptData;
if (mentionsCount(promptData) === 0) {
transformedPromptData = attachLastGeneratedThemeMention(promptData);
}
addUserMessage({
promptData: transformedPromptData,
});
const result = await generateTheme(buildPrompt(transformedPromptData));
if (!result) {
addAssistantMessage({
content: "Failed to generate theme.",
});
return;
}
addAssistantMessage({
content:
result?.text ??
(result?.theme ? "Here's the theme I generated for you." : "Failed to generate theme."),
themeStyles: result?.theme,
});
};
const handleRetry = async (messageIndex: number) => {
const messageToRetry = messages[messageIndex];
if (!messageToRetry || messageToRetry.role !== "user" || !messageToRetry.promptData) {
toast({
title: "Error",
description: "Cannot retry this message.",
});
return;
}
// Reset messages up to the retry point (remove the user message and any subsequent messages)
resetMessagesUpToIndex(messageIndex);
// Resend the prompt
await handleThemeGeneration(messageToRetry.promptData);
};
usePostLoginAction("AI_GENERATE_FROM_CHAT", ({ promptData }) => {
handleThemeGeneration(promptData);
});
return (
<section className="@container relative isolate z-1 mx-auto flex h-full w-full max-w-[49rem] flex-1 flex-col justify-center">
<div
className={cn(
"relative flex w-full flex-1 flex-col overflow-y-hidden transition-all duration-300 ease-out"
)}
>
{hasMessages ? (
<ChatMessages onRetry={handleRetry} />
) : (
<div className="animate-in fade-in-50 zoom-in-95 relative isolate px-4 pt-8 duration-300 ease-out sm:pt-16 md:pt-24">
<NoMessagesPlaceholder handleThemeGeneration={handleThemeGeneration} />
</div>
)}
</div>
{/* Chat form input and suggestions */}
<div className="relative mx-auto flex w-full flex-col px-4 pb-4">
<div className="relative isolate z-10 w-full">
<div
className={cn(
"transition-all ease-out",
hasMessages ? "scale-100 opacity-100" : "h-0 scale-80 opacity-0"
)}
>
<ClosableSuggestedPillActions handleThemeGeneration={handleThemeGeneration} />
</div>
<AIChatForm handleThemeGeneration={handleThemeGeneration} />
</div>
</div>
</section>
);
}
@@ -0,0 +1,31 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ComponentProps } from "react";
export interface AIPillActionButtonProps extends ComponentProps<typeof Button> {}
export function AIPillActionButton({
className,
children,
disabled,
...props
}: AIPillActionButtonProps) {
return (
<div className={cn("group/pill relative active:scale-95", disabled && "pointer-events-none")}>
<div className="from-primary to-background absolute inset-0 z-[-1] rounded-full bg-gradient-to-br opacity-0 transition-all duration-150 ease-in group-hover/pill:opacity-30" />
<Button
variant="ghost"
size="sm"
className={cn(
"hover:bg-muted/50 text-muted-foreground hover:text-foreground border-border/80! rounded-full border bg-transparent px-2 font-medium text-nowrap whitespace-nowrap backdrop-blur-md transition-all duration-150 ease-in select-none focus:outline-none",
"group-hover/pill:inset-shadow-primary/50 h-7 inset-shadow-2xs inset-shadow-transparent [&>svg]:size-3",
className
)}
disabled={disabled}
{...props}
>
{children}
</Button>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import Logo from "@/assets/logo.svg";
import { CopyButton } from "@/components/copy-button";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAIChat } from "@/hooks/use-ai-chat";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { type ChatMessage as ChatMessageType } from "@/types/ai";
import { ThemeStyles } from "@/types/theme";
import { buildAIPromptRender } from "@/utils/ai-prompt";
import { Goal, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import ColorPreview from "../theme-preview/color-preview";
import { ChatThemePreview } from "./chat-theme-preview";
import { LoadingLogo } from "./loading-logo";
type ChatMessagesProps = {
onRetry?: (messageIndex: number) => void;
};
export function ChatMessages({ onRetry }: ChatMessagesProps) {
const [isScrollTop, setIsScrollTop] = useState(true);
const { messages } = useAIChat();
const { loading: isAIGenerating } = useAIThemeGeneration();
const previousMessages = useRef<ChatMessageType[]>(messages);
const messagesStartRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (messagesEndRef.current) {
// When switching tabs, messages do not change, so we don't need to animate the scroll
const didMessagesChange = previousMessages.current.length !== messages.length;
messagesEndRef.current.scrollIntoView({ behavior: didMessagesChange ? "smooth" : "auto" });
// Update the previous messages ref
previousMessages.current = messages;
}
}, [messages, isAIGenerating]);
// Toggle top fade out effect when scrolling
useEffect(() => {
const startMarker = messagesStartRef.current;
if (!startMarker) return;
const observerOptions = {
root: null, // Use viewport as root for more reliable detection
threshold: 0,
};
const startMessagesObserver = new IntersectionObserver(([entry]) => {
setIsScrollTop(entry.isIntersecting);
}, observerOptions);
startMessagesObserver.observe(startMarker);
return () => startMessagesObserver.disconnect();
}, []);
return (
<ScrollArea className="relative isolate flex flex-1 flex-col">
{/* Top fade out effect when scrolling */}
<div
className={cn(
"via-background/50 from-background pointer-events-none absolute top-0 right-0 left-0 z-20 h-8 bg-gradient-to-b to-transparent opacity-100 transition-opacity ease-out",
isScrollTop ? "opacity-0" : "opacity-100"
)}
/>
<div className="relative size-full flex-1 px-6 pt-2 pb-8">
<div ref={messagesStartRef} />
<div className="flex flex-col gap-8 text-pretty wrap-anywhere">
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
messageIndex={index}
onRetry={onRetry}
/>
))}
{/* Loading message when AI is generating */}
{isAIGenerating && (
<div className="flex gap-1.5 pb-8">
<div className="relative flex size-6 items-center justify-center">
<LoadingLogo />
</div>
<p className="inline-flex animate-pulse gap-0.25 delay-150">
<span className="text-sm">Generating</span>
<span className="animate-bounce delay-100">.</span>
<span className="animate-bounce delay-200">.</span>
<span className="animate-bounce delay-300">.</span>
</p>
</div>
)}
</div>
<div ref={messagesEndRef} />
</div>
</ScrollArea>
);
}
type ChatMessageProps = {
message: ChatMessageType;
messageIndex?: number;
onRetry?: (messageIndex: number) => void;
};
export default function ChatMessage({ message, messageIndex, onRetry }: ChatMessageProps) {
const isUser = message.role === "user";
const isAssistant = message.role === "assistant";
const { themeState, setThemeState } = useEditorStore();
const { loading: isAIGenerating } = useAIThemeGeneration();
const handleResetThemeToMessageCheckpoint = (themeStyles?: ThemeStyles) => {
if (!themeStyles) return;
setThemeState({
...themeState,
styles: themeStyles,
});
};
const handleRetry = () => {
if (messageIndex !== undefined && onRetry) {
onRetry(messageIndex);
}
};
const getDisplayContent = () => {
if (isUser && message.promptData) {
return buildAIPromptRender(message.promptData);
}
return message.content || "";
};
const getCopyContent = () => {
if (isUser && message.promptData) {
return message.promptData.content;
}
return message.content || "";
};
return (
<div className={cn("flex items-start gap-4", isUser ? "justify-end" : "justify-start")}>
<div
className={cn("flex w-full max-w-[90%] items-start gap-1.5", isUser && "flex-row-reverse")}
>
{isAssistant && (
<div className="border-border/50! bg-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border select-none">
<Logo className="text-background size-full p-0.5" />
</div>
)}
<div className={cn("group/message relative", isAssistant && "w-full")}>
<p
className={cn(
"bg-red w-fit text-sm",
isUser && "bg-muted/80 text-foreground/80 border-border/50! rounded-lg border p-4"
)}
>
{getDisplayContent()}
</p>
{isAssistant && message.themeStyles && (
<div className="mt-2">
<ChatThemePreview themeStyles={message.themeStyles} className="p-0">
<ScrollArea className="h-48">
<div className="p-2">
<ColorPreview
styles={message.themeStyles}
currentMode={themeState.currentMode}
/>
</div>
</ScrollArea>
</ChatThemePreview>
</div>
)}
<div
className={cn(
"mt-2 flex gap-2 opacity-0 transition-opacity duration-300 ease-out group-hover/message:opacity-100",
isUser ? "justify-end" : "justify-start"
)}
>
{isUser && onRetry && messageIndex !== undefined && (
<TooltipWrapper label="Retry this prompt" asChild>
<Button
size="icon"
variant="ghost"
className="size-6 [&>svg]:size-3.5"
disabled={isAIGenerating}
onClick={handleRetry}
>
<RefreshCw />
</Button>
</TooltipWrapper>
)}
<CopyButton textToCopy={getCopyContent()} />
{isAssistant && message.themeStyles && (
<TooltipWrapper label="Reset to this checkpoint" asChild>
<Button
size="icon"
variant="ghost"
className="size-6 [&>svg]:size-3.5"
disabled={isAIGenerating}
onClick={() => handleResetThemeToMessageCheckpoint(message.themeStyles)}
>
<Goal />
</Button>
</TooltipWrapper>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useTheme } from "@/components/theme-provider";
import { cn } from "@/lib/utils";
import { ThemeStyles } from "@/types/theme";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ComponentProps, useState } from "react";
interface ChatThemePreviewProps extends ComponentProps<"div"> {
themeStyles: ThemeStyles;
}
export function ChatThemePreview({
themeStyles,
className,
children,
...props
}: ChatThemePreviewProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { theme: mode } = useTheme();
return (
<div className={cn("border-border/75! max-w-[550px] overflow-hidden rounded-lg border")}>
<div
className={cn(
"group/control bg-background hover:bg-muted/40 flex h-10 w-full shrink-0 cursor-pointer items-center justify-between gap-4 p-2.5 transition-colors duration-300 ease-in-out",
isExpanded ? "bg-muted/40" : "border-transparent"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<h3
className={cn(
"group-hover/control:text-foreground text-foreground/75 text-sm font-medium",
isExpanded && "text-foreground"
)}
>
Preview
</h3>
<div className="flex gap-0.5">
<div
className="border-muted bg-primary h-3 w-3 rounded-sm border"
style={{ backgroundColor: themeStyles[mode].primary }}
/>
<div
className="border-muted bg-secondary h-3 w-3 rounded-sm border"
style={{ backgroundColor: themeStyles[mode].secondary }}
/>
<div
className="border-muted bg-background h-3 w-3 rounded-sm border"
style={{ backgroundColor: themeStyles[mode].background }}
/>
<div
className="border-muted bg-muted h-3 w-3 rounded-sm border"
style={{ backgroundColor: themeStyles[mode].muted }}
/>
<div
className="border-muted bg-accent h-3 w-3 rounded-sm border"
style={{ backgroundColor: themeStyles[mode].accent }}
/>
<div
className="border-muted bg-border h-3 w-3 rounded-sm border"
style={{ backgroundColor: themeStyles[mode].border }}
/>
</div>
<button
type="button"
className="text-foreground/75 group-hover/control:text-foreground ml-auto transition-colors"
aria-label={isExpanded ? "Collapse section" : "Expand section"}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
</div>
<div
className={cn(
"grid transition-all duration-300 ease-in-out",
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="bg-background overflow-hidden">
<div className={cn("space-y-2 p-2.5", className)} {...props}>
{children}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
"use client";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { Button } from "@/components/ui/button";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { PROMPTS } from "@/utils/prompts";
import { createCurrentThemePrompt } from "@/utils/ai-prompt";
import { AIPromptData } from "@/types/ai";
import { Sparkles, X } from "lucide-react";
import { useState } from "react";
import { AIPillActionButton } from "./ai-pill-action-button";
export function ClosableSuggestedPillActions({
handleThemeGeneration,
}: {
handleThemeGeneration: (promptData: AIPromptData | null) => void;
}) {
const [hasClosedSuggestions, setHasClosedSuggestions] = useState(false);
const { loading: aiIsGenerating } = useAIThemeGeneration();
const handleSetPrompt = async (prompt: string) => {
const promptData = createCurrentThemePrompt({ prompt });
handleThemeGeneration(promptData);
};
if (hasClosedSuggestions) return null;
return (
<div className="relative flex flex-col items-center justify-center">
{/* Fade out effect when scrolling */}
<div className="via-background/50 from-background pointer-events-none absolute -top-8 right-0 left-0 z-20 h-8 bg-gradient-to-t to-transparent opacity-100 transition-opacity ease-out" />
<div className="flex w-full items-center justify-between gap-4">
<h3 className="text-muted-foreground text-xs">Suggestions</h3>
<Button
variant="ghost"
size="icon"
className="size-6 [&>svg]:size-3"
onClick={() => setHasClosedSuggestions(true)}
>
<X />
</Button>
</div>
<HorizontalScrollArea className="pt-1 pb-2">
{Object.entries(PROMPTS).map(([key, { label, prompt }]) => (
<AIPillActionButton
key={key}
onClick={() => handleSetPrompt(prompt)}
disabled={aiIsGenerating}
>
<Sparkles /> {label}
</AIPillActionButton>
))}
</HorizontalScrollArea>
</div>
);
}
@@ -6,11 +6,11 @@ export function LoadingLogo() {
<>
<div className="bg-primary/90 absolute inset-0 -z-1 m-auto size-[65%] animate-ping rounded-full" />
<div className="bg-primary/30 absolute inset-0 -z-1 m-auto size-[85%] animate-ping rounded-full delay-100" />
<div className="bg-background relative isolate mx-auto size-full rounded-full p-1">
<div className="bg-background relative isolate mx-auto size-full rounded-full">
<div className="absolute top-0 left-0 size-[30%]">
<Sparkle className="size-full animate-pulse fill-current" />
</div>
<Logo className="size-full animate-pulse delay-150" />
<Logo className="size-full animate-pulse p-0.5 delay-150" />
<div className="absolute right-0 bottom-0 size-[20%]">
<Sparkle className="size-full animate-pulse fill-current delay-300" />
</div>
@@ -0,0 +1,210 @@
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { AIPromptData } from "@/types/ai";
import { createCurrentThemePrompt } from "@/utils/ai-prompt";
import { PROMPTS } from "@/utils/prompts";
import { Blend, PaintRoller, WandSparkles } from "lucide-react";
import { ComponentProps, Fragment } from "react";
import TabsTriggerPill from "../theme-preview/tabs-trigger-pill";
interface RemixPrompt {
displayContent: string;
prompt: string;
basePreset: string;
}
interface Prompt {
displayContent: string;
prompt: string;
}
const CREATE_PROMPTS: Prompt[] = [
{
displayContent: "JavaScript/TypeScript Advent of Code playground",
prompt:
"Create a retro JavaScript Advent of Code theme. Use a grayish background with JavaScript yellow and TypeScript blue as primary/secondary colors. Change all fonts to monospace. Make borders sharp.",
},
{
displayContent: "Retro Terminal UI, green phosphor glow",
prompt:
"Create a retro terminal theme with black background (in dark mode), phosphorescent green primary and text, and all fonts monospace.",
},
{
displayContent: "Monochrome Manga-inspired theme",
prompt:
"Create a Manga-inspired theme. Monochromatic palette (black, off-white, grays), square corners, small contrast solid offset shadows, and high-contrast borders (black on light, off-white on dark). Use a playful font, like Architects daughter.",
},
{
displayContent: "I want a minimal Ghibli Studio vibe",
prompt:
"Generate a theme inspired by Studio Ghibli — soft pastels, natural greens, organic colors, and hand-drawn charm.",
},
];
const REMIX_PROMPTS: RemixPrompt[] = [
{
displayContent: "Make @Twitter but in a slick purple",
prompt: "Make @Twitter but in a slick purple",
basePreset: "twitter",
},
{
displayContent: "What if @Supabase was vibrant blue?",
prompt: "Make @Supabase but in vibrant blue",
basePreset: "supabase",
},
{
displayContent: "I want @Doom 64 with muted colors",
prompt: "I want @Doom 64 with alternate colors",
basePreset: "doom-64",
},
];
const VARIANT_PROMPTS: Prompt[] = [
{
displayContent: "Make my @Current Theme minimalistic",
prompt: PROMPTS.minimalStyle.prompt,
},
{
displayContent: "Flatten the colors of my @Current Theme",
prompt: PROMPTS.flatDesign.prompt,
},
{
displayContent: "Create a brutalist variant of my @Current Theme",
prompt: PROMPTS.brutalist.prompt,
},
];
// This can be moved to the ai-prompt utils
const createPromptDataFromPreset = (prompt: string, presetName: string): AIPromptData => {
const preset = useThemePresetStore.getState().getPreset(presetName);
if (!preset) {
throw new Error(`Preset "${presetName}" not found`);
}
return {
content: prompt,
mentions: [
{
id: presetName,
label: preset.label ?? presetName,
themeData: {
light: preset.styles.light || {},
dark: preset.styles.dark || {},
},
},
],
};
};
export function NoMessagesPlaceholder({
handleThemeGeneration,
}: {
handleThemeGeneration: (promptData: AIPromptData | null) => void;
}) {
const { data: session } = authClient.useSession();
const { loading: isGenerating } = useAIThemeGeneration();
const userName = session?.user.name?.split(" ")[0];
const heading = `What can I help you theme${userName ? `, ${userName}` : ""}?`;
return (
<div className="mx-auto flex max-w-lg flex-col gap-4">
<h2 className="text-[clamp(22px,6cqw,32px)] font-semibold tracking-tighter text-pretty">
{heading}
</h2>
<Tabs defaultValue="create-prompts">
<HorizontalScrollArea className="mb-1">
<TabsList className="m-0 bg-transparent p-0">
<TabsTriggerPill value="create-prompts" className="flex items-center gap-1">
<PaintRoller className="size-3.5" aria-hidden="true" />
Create
</TabsTriggerPill>
<TabsTriggerPill value="variant-prompts" className="flex items-center gap-1">
<Blend className="size-3.5" aria-hidden="true" />
Remix
</TabsTriggerPill>
<TabsTriggerPill value="tweak-prompts" className="flex items-center gap-1">
<WandSparkles className="size-3.5" aria-hidden="true" />
Tweak
</TabsTriggerPill>
</TabsList>
</HorizontalScrollArea>
<TabsContent value="create-prompts">
{CREATE_PROMPTS.map((prompt, index) => (
<Fragment key={`create-${index}`}>
<PromptButton
disabled={isGenerating}
onClick={() =>
handleThemeGeneration({
content: prompt.prompt,
mentions: [],
})
}
>
{prompt.displayContent}
</PromptButton>
{index < CREATE_PROMPTS.length - 1 && <Separator className="bg-border/50" />}
</Fragment>
))}
</TabsContent>
<TabsContent value="variant-prompts">
{REMIX_PROMPTS.map((prompt, index) => (
<Fragment key={`variant-${index}`}>
<PromptButton
disabled={isGenerating}
onClick={() =>
handleThemeGeneration(
createPromptDataFromPreset(prompt.prompt, prompt.basePreset)
)
}
>
{prompt.displayContent}
</PromptButton>
{index < REMIX_PROMPTS.length - 1 && <Separator className="bg-border/50" />}
</Fragment>
))}
</TabsContent>
<TabsContent value="tweak-prompts">
{VARIANT_PROMPTS.map((prompt, index) => (
<Fragment key={`variant-${index}`}>
<PromptButton
disabled={isGenerating}
onClick={() =>
handleThemeGeneration(createCurrentThemePrompt({ prompt: prompt.prompt }))
}
>
{prompt.displayContent}
</PromptButton>
{index < VARIANT_PROMPTS.length - 1 && <Separator className="bg-border/50" />}
</Fragment>
))}
</TabsContent>
</Tabs>
</div>
);
}
interface PromptButtonProps extends ComponentProps<typeof Button> {}
function PromptButton({ className, children, ...props }: PromptButtonProps) {
return (
<Button
variant="ghost"
className={cn("text-muted-foreground w-full justify-start font-normal", className)}
{...props}
>
<span className="truncate">{children}</span>
</Button>
);
}
+29 -44
View File
@@ -189,13 +189,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="px-2 w-full justify-start">
<Button variant="ghost" size="sm" className="w-full justify-start px-2">
<Contrast className="h-4 w-4" />
<span className="text-sm hidden md:block">Contrast</span>
Contrast
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[80vh] p-0 py-6 overflow-hidden rounded-lg border shadow-lg gap-6 flex flex-col">
<div className="flex justify-between items-end px-6 sm:flex-row flex-col gap-4">
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col gap-6 overflow-hidden rounded-lg border p-0 py-6 shadow-lg">
<div className="flex flex-col items-end justify-between gap-4 px-6 sm:flex-row">
<DialogHeader>
<DialogTitle>Contrast Checker</DialogTitle>
<DialogDescription>
@@ -204,14 +204,14 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
href="https://www.w3.org/TR/WCAG21/"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:text-primary/80 transition-colors"
className="text-primary hover:text-primary/80 underline transition-colors"
>
Learn more
</a>
</DialogDescription>
</DialogHeader>
<div className="items-center gap-2 hidden md:flex">
<div className="hidden items-center gap-2 md:flex">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -242,13 +242,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
size="sm"
onClick={() => setFilter("issues")}
>
<AlertTriangle className={cn("h-3 w-3 mr-1")} />
<AlertTriangle className={cn("mr-1 h-3 w-3")} />
Issues ({totalIssues})
</Button>
</div>
</div>
<ScrollArea className="flex-1 relative flex flex-col">
<ScrollArea className="relative flex flex-1 flex-col">
<div className="space-y-6 px-6">
{groupedPairs.map((group) => (
<div key={group.category} className="space-y-4">
@@ -256,35 +256,29 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
<h2 className="text-md font-semibold">{group.label}</h2>
<Separator className="flex-1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{group.pairs.map((pair) => {
const result = getContrastResult(pair.id);
const isValid =
result?.contrastRatio !== undefined &&
result?.contrastRatio >= MIN_CONTRAST_RATIO;
const contrastRatio =
result?.contrastRatio?.toFixed(2) ?? "N/A";
const contrastRatio = result?.contrastRatio?.toFixed(2) ?? "N/A";
return (
<Card
key={pair.id}
className={cn(
"transition-all duration-200",
!isValid && " border-dashed"
)}
className={cn("transition-all duration-200", !isValid && "border-dashed")}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="mb-3 flex items-center justify-between">
<h3
className={cn(
"font-medium flex items-center",
"flex items-center font-medium",
!isValid && "text-destructive"
)}
>
{pair.label}
{!isValid && (
<AlertTriangle className="size-3.5 ml-1" />
)}
{!isValid && <AlertTriangle className="ml-1 size-3.5" />}
</h3>
<Badge
variant={isValid ? "default" : "destructive"}
@@ -309,21 +303,18 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
</Badge>
</div>
<div className="flex gap-2 items-center">
<div className="flex flex-col items-center gap-3 flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-1 flex-col items-center gap-3">
<div className="flex w-full items-center gap-3">
<div
style={{
backgroundColor:
pair.background ?? "#000000",
backgroundColor: pair.background ?? "#000000",
}}
className="h-12 w-12 rounded-md border shadow-sm flex-shrink-0"
className="h-12 w-12 flex-shrink-0 rounded-md border shadow-sm"
></div>
<div className="flex flex-col">
<span className="text-xs font-medium">
Background
</span>
<span className="text-xs text-muted-foreground font-mono">
<span className="text-xs font-medium">Background</span>
<span className="text-muted-foreground font-mono text-xs">
{pair.background}
</span>
</div>
@@ -332,16 +323,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
<div className="flex w-full items-center gap-3">
<div
style={{
backgroundColor:
pair.foreground ?? "#ffffff",
backgroundColor: pair.foreground ?? "#ffffff",
}}
className="h-12 w-12 rounded-md border shadow-sm flex-shrink-0"
className="h-12 w-12 flex-shrink-0 rounded-md border shadow-sm"
></div>
<div className="flex flex-col">
<span className="text-xs font-medium">
Foreground
</span>
<span className="text-xs text-muted-foreground font-mono">
<span className="text-xs font-medium">Foreground</span>
<span className="text-muted-foreground font-mono text-xs">
{pair.foreground}
</span>
</div>
@@ -350,16 +338,15 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
<div
style={{
backgroundColor:
pair.background ?? "transparent",
backgroundColor: pair.background ?? "transparent",
}}
className="flex-1 h-full min-h-[120px] rounded-lg border shadow-sm flex items-center justify-center overflow-hidden"
className="flex h-full min-h-[120px] flex-1 items-center justify-center overflow-hidden rounded-lg border shadow-sm"
>
{pair.foreground && pair.background ? (
<div className="text-center p-4">
<div className="p-4 text-center">
<p
style={{ color: pair.foreground }}
className="text-4xl font-bold tracking-wider mb-2"
className="mb-2 text-4xl font-bold tracking-wider"
>
Aa
</p>
@@ -371,9 +358,7 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
Preview
</p>
<p className="text-muted-foreground text-xs">Preview</p>
)}
</div>
</div>
+110 -16
View File
@@ -1,22 +1,74 @@
"use client";
import React, { useEffect } from "react";
import { useEditor, EditorContent, JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { suggestion } from "@/components/editor/mention-suggestion";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { AIPromptData, MentionReference } from "@/types/ai";
import CharacterCount from "@tiptap/extension-character-count";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { suggestion } from "@/components/editor/mention-suggestion"; // We'll create this next
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { EditorContent, JSONContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React, { useEffect } from "react";
interface CustomTextareaProps {
onContentChange: (textContent: string, jsonContent: JSONContent) => void;
onContentChange: (promptData: AIPromptData) => void;
onGenerate?: () => void;
characterLimit?: number;
}
const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGenerate }) => {
const convertJSONContentToPromptData = (jsonContent: JSONContent): AIPromptData => {
const content =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jsonContent.content?.[0]?.content?.reduce((text: string, node: any) => {
if (node.type === "text") return text + node.text;
if (node.type === "mention") return text + `@${node.attrs?.label}`;
return text;
}, "") || "";
const mentions: MentionReference[] =
jsonContent.content?.[0]?.content
// eslint-disable-next-line @typescript-eslint/no-explicit-any
?.filter((node: any) => node.type === "mention")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
?.map((mention: any) => {
const id = mention.attrs?.id;
const label = mention.attrs?.label;
let themeData;
if (id === "editor:current-changes") {
themeData = useEditorStore.getState().themeState.styles;
} else {
const preset = useThemePresetStore.getState().getPreset(id);
themeData = preset?.styles || { light: {}, dark: {} };
}
return {
id,
label,
themeData,
};
}) || [];
return {
content,
mentions,
};
};
const CustomTextarea: React.FC<CustomTextareaProps> = ({
onContentChange,
onGenerate,
characterLimit,
}) => {
const { loading: aiGenerateLoading } = useAIThemeGeneration();
const { toast } = useToast();
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
Mention.configure({
@@ -30,32 +82,31 @@ const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGene
emptyEditorClass:
"cursor-text before:content-[attr(data-placeholder)] before:absolute before:top-2 before:left-3 before:text-mauve-11 before:opacity-50 before-pointer-events-none",
}),
CharacterCount.configure({
limit: characterLimit,
}),
],
autofocus: !aiGenerateLoading,
editorProps: {
attributes: {
class:
"min-h-[60px] max-h-[150px] wrap-anywhere text-foreground/90 scrollbar-thin overflow-y-auto w-full rounded-md bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50",
"min-h-[60px] max-h-[150px] wrap-anywhere text-foreground/90 scrollbar-thin overflow-y-auto w-full rounded-md bg-background px-3 py-2 pb-6 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50 max-sm:text-[16px]!",
},
handleKeyDown: (view, event) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.key === "Enter" && !event.shiftKey && !aiGenerateLoading) {
const { state } = view;
const mentionPluginKey = Mention.options.suggestion.pluginKey;
// Ensure the plugin key exists before trying to get state
if (!mentionPluginKey) {
console.error("Mention plugin key not found.");
// Fallback: allow default Enter behavior if key is missing
return false;
}
const mentionState = mentionPluginKey.getState(state);
if (mentionState?.active) {
// Mention list is active, let the mention extension handle Enter.
return false;
} else {
// Mention list is not active, submit the prompt.
event.preventDefault();
onGenerate?.();
return true;
@@ -63,15 +114,38 @@ const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGene
}
return false;
},
handlePaste: (_view, event) => {
if (!characterLimit) return false;
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
const pastedText = clipboardData.getData("text/plain");
const currentCharacterCount = editor?.storage.characterCount.characters() || 0;
const totalCharacters = currentCharacterCount + pastedText.length;
if (totalCharacters > characterLimit) {
event.preventDefault();
toast({
title: "Text too long",
description: `The pasted content would exceed the ${characterLimit} character limit.`,
variant: "destructive",
});
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
onContentChange(editor.getText(), editor.getJSON());
const jsonContent = editor.getJSON();
const promptData = convertJSONContentToPromptData(jsonContent);
onContentChange(promptData);
},
});
useEffect(() => {
if (editor) {
editor.setEditable(!aiGenerateLoading);
editor.commands.blur();
}
}, [aiGenerateLoading, editor]);
@@ -80,7 +154,27 @@ const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGene
return null;
}
return <EditorContent editor={editor} />;
const characterCount = editor.storage.characterCount.characters();
const isLimitExceeded = characterLimit && characterCount > characterLimit;
const shouldShowCount = characterLimit && characterCount >= characterLimit * 0.9;
return (
<div className="relative isolate">
<EditorContent editor={editor} />
{shouldShowCount && (
<div className="pointer-events-none absolute right-3 bottom-2 z-10 flex text-xs">
<span
className={cn(
"bg-background/10 rounded-full px-0.5 backdrop-blur-xs",
isLimitExceeded ? "text-destructive" : "text-muted-foreground"
)}
>
{characterCount} / {characterLimit}
</span>
</div>
)}
</div>
);
};
export default CustomTextarea;
+19 -17
View File
@@ -3,14 +3,14 @@
import React, { useEffect, use } from "react";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EditorConfig } from "@/types/editor";
import { Theme, ThemeStyles } from "@/types/theme";
import { Sliders } from "lucide-react";
import { useEditorStore } from "@/store/editor-store";
import { ActionBar } from "./action-bar/action-bar";
import ThemeControlPanel from "./theme-control-panel";
import ThemePreviewPanel from "./theme-preview-panel";
interface EditorProps {
config: EditorConfig;
themePromise: Promise<Theme | null>;
}
@@ -24,11 +24,9 @@ const isThemeStyles = (styles: unknown): styles is ThemeStyles => {
);
};
const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
const Editor: React.FC<EditorProps> = ({ themePromise }) => {
const themeState = useEditorStore((state) => state.themeState);
const setThemeState = useEditorStore((state) => state.setThemeState);
const Controls = config.controls;
const Preview = config.preview;
const initialTheme = themePromise ? use(themePromise) : null;
@@ -62,13 +60,18 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
const styles = themeState.styles;
return (
<div className="h-full overflow-hidden">
<div className="relative isolate flex flex-1 overflow-hidden">
{/* Desktop Layout */}
<div className="hidden h-full md:block">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={30} minSize={20} maxSize={40}>
<div className="flex h-full flex-col">
<Controls
<div className="hidden size-full md:block">
<ResizablePanelGroup direction="horizontal" className="isolate">
<ResizablePanel
defaultSize={30}
minSize={20}
maxSize={40}
className="z-1 min-w-[max(20%,22rem)]"
>
<div className="relative isolate flex h-full flex-1 flex-col">
<ThemeControlPanel
styles={styles}
onChange={handleStyleChange}
currentMode={themeState.currentMode}
@@ -77,19 +80,18 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={70} minSize={20}>
<ResizablePanel defaultSize={70}>
<div className="flex h-full flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<ActionBar />
<Preview styles={styles} currentMode={themeState.currentMode} />
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* Mobile Layout */}
<div className="h-full md:hidden">
<div className="h-full w-full flex-1 overflow-hidden md:hidden">
<Tabs defaultValue="controls" className="h-full">
<TabsList className="w-full rounded-none">
<TabsTrigger value="controls" className="flex-1">
@@ -102,7 +104,7 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
</TabsList>
<TabsContent value="controls" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<Controls
<ThemeControlPanel
styles={styles}
onChange={handleStyleChange}
currentMode={themeState.currentMode}
@@ -113,7 +115,7 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
<TabsContent value="preview" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<ActionBar />
<Preview styles={styles} currentMode={themeState.currentMode} />
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
</div>
</TabsContent>
</Tabs>
+98 -88
View File
@@ -1,11 +1,6 @@
"use client";
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import React, { forwardRef, useEffect, useImperativeHandle, useState, useRef } from "react";
import { cn } from "@/lib/utils";
// Define the structure of the theme item object
@@ -25,97 +20,112 @@ export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
(props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
export const MentionList = forwardRef<MentionListRef, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLButtonElement>(null);
// Function to select item (adapted from reference)
const selectItem = (index: number) => {
console.log("selectItem", index);
const item = props.items[index];
if (item) {
// Pass the whole item object to the command function
props.command(item);
// Function to select item (adapted from reference)
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
// Pass the whole item object to the command function
props.command(item);
}
};
// Arrow key handlers using modulo (adapted from reference)
const upHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
// Auto-scroll effect when selectedIndex changes
useEffect(() => {
if (selectedItemRef.current && containerRef.current) {
selectedItemRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
// Use modulo handlers
upHandler();
return true;
}
};
// Arrow key handlers using modulo (adapted from reference)
const upHandler = () => {
setSelectedIndex(
(prevIndex) => (prevIndex + props.items.length - 1) % props.items.length
);
};
if (event.key === "ArrowDown") {
// Use modulo handlers
downHandler();
return true;
}
const downHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
};
if (event.key === "Enter") {
// Use enter handler
enterHandler();
return true;
}
const enterHandler = () => {
selectItem(selectedIndex);
};
if (event.key === "Tab") {
// Use tab handler
enterHandler();
return true;
}
useEffect(() => setSelectedIndex(0), [props.items]);
return false;
},
}));
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
// Use modulo handlers
upHandler();
return true;
}
if (event.key === "ArrowDown") {
// Use modulo handlers
downHandler();
return true;
}
if (event.key === "Enter") {
// Use enter handler
enterHandler();
return true;
}
return false;
},
}));
return (
<div
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
)}
style={{ position: "absolute", pointerEvents: "auto" }}
>
{props.items.length ? (
props.items.map((item, index) => (
<button
// Use Tailwind classes mimicking shadcn/ui DropdownMenuItem with cn utility
className={cn(
"relative flex w-full items-center rounded-sm p-1.5 text-xs outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
key={item.id} // Use item.id as the key
onClick={(e) => {
e.stopPropagation();
selectItem(index);
}}
>
{item.label}
</button>
))
) : (
<div
return (
<div
ref={containerRef}
className={cn(
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"scrollbar-thin max-h-[180px] overflow-y-auto"
)}
>
{props.items.length ? (
props.items.map((item, index) => (
<button
ref={index === selectedIndex ? selectedItemRef : null}
// Use Tailwind classes mimicking shadcn/ui DropdownMenuItem with cn utility
className={cn(
"relative flex cursor-default select-none items-center rounded-sm p-1.5 text-xs text-muted-foreground"
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex w-full items-center rounded-sm p-1.5 text-xs transition-colors outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
key={item.id} // Use item.id as the key
onClick={(e) => {
e.stopPropagation();
selectItem(index);
}}
>
No result
</div>
)}
</div>
);
}
);
{item.label}
</button>
))
) : (
<div
className={cn(
"text-muted-foreground relative flex cursor-default items-center rounded-sm p-1.5 text-xs select-none"
)}
>
No result
</div>
)}
</div>
);
});
MentionList.displayName = "MentionList";
+59 -36
View File
@@ -1,35 +1,35 @@
"use client";
import { AlertCircle, Sparkles } from "lucide-react";
import React, { use } from "react";
import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme";
import ControlSection from "./control-section";
import ColorPicker from "./color-picker";
import { ScrollArea } from "../ui/scroll-area";
import ThemePresetSelect from "./theme-preset-select";
import {
getAppliedThemeFont,
monoFonts,
sansSerifFonts,
serifFonts,
} from "../../utils/theme-fonts";
import { useEditorStore } from "../../store/editor-store";
import { Label } from "../ui/label";
import { SliderWithInput } from "./slider-with-input";
import { Tabs, TabsList, TabsContent } from "../ui/tabs";
import ThemeFontSelect from "./theme-font-select";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import {
COMMON_STYLES,
DEFAULT_FONT_MONO,
DEFAULT_FONT_SANS,
DEFAULT_FONT_SERIF,
defaultThemeState,
} from "../../config/theme";
import { Separator } from "../ui/separator";
import { AlertCircle } from "lucide-react";
import ShadowControl from "./shadow-control";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
import ThemeEditActions from "./theme-edit-actions";
} from "@/config/theme";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { useControlsTabFromUrl, type ControlTab } from "@/hooks/use-controls-tab-from-url";
import { useEditorStore } from "@/store/editor-store";
import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme";
import { getAppliedThemeFont, monoFonts, sansSerifFonts, serifFonts } from "@/utils/theme-fonts";
import { HorizontalScrollArea } from "../horizontal-scroll-area";
import { AIInterface } from "./ai/ai-interface";
import ColorPicker from "./color-picker";
import ControlSection from "./control-section";
import HslAdjustmentControls from "./hsl-adjustment-controls";
import ShadowControl from "./shadow-control";
import { SliderWithInput } from "./slider-with-input";
import ThemeEditActions from "./theme-edit-actions";
import ThemeFontSelect from "./theme-font-select";
import ThemePresetSelect from "./theme-preset-select";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
const ThemeControlPanel = ({
styles,
@@ -38,6 +38,8 @@ const ThemeControlPanel = ({
themePromise,
}: ThemeEditorControlsProps) => {
const { themeState } = useEditorStore();
const { tab, handleSetTab } = useControlsTabFromUrl();
const { loading: aiGenerationLoading } = useAIThemeGeneration();
const currentStyles = React.useMemo(
() => ({
@@ -83,23 +85,36 @@ const ThemeControlPanel = ({
<>
<div className="border-b">
{!theme ? (
<ThemePresetSelect className="h-14 rounded-none" />
<ThemePresetSelect className="h-14 rounded-none" disabled={aiGenerationLoading} />
) : (
<ThemeEditActions theme={theme} />
<ThemeEditActions theme={theme} disabled={aiGenerationLoading} />
)}
</div>
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<Tabs defaultValue="colors" className="flex min-h-0 w-full flex-1 flex-col">
<div className="mt-2 px-4">
<Tabs
value={tab}
onValueChange={(v) => handleSetTab(v as ControlTab)}
className="flex min-h-0 w-full flex-1 flex-col"
>
<HorizontalScrollArea className="mt-2 mb-1 px-4">
<TabsList className="bg-background text-muted-foreground inline-flex w-fit items-center justify-center rounded-full px-0">
<TabsTriggerPill value="colors">Colors</TabsTriggerPill>
<TabsTriggerPill value="typography">Typography</TabsTriggerPill>
<TabsTriggerPill value="other">Other</TabsTriggerPill>
<TabsTriggerPill
value="ai"
className="data-[state=active]:[--effect:var(--secondary-foreground)] data-[state=active]:[--foreground:var(--muted-foreground)] data-[state=active]:[--muted-foreground:var(--effect)]"
>
<Sparkles className="mr-1 size-3.5 text-current" />
<span className="animate-text via-foreground from-muted-foreground to-muted-foreground flex items-center gap-1 bg-gradient-to-r from-50% via-60% to-100% bg-[200%_auto] bg-clip-text text-sm text-transparent">
Generate
</span>
</TabsTriggerPill>
</TabsList>
</div>
</HorizontalScrollArea>
<ScrollArea className="h-full flex-1 p-4 pt-0">
<TabsContent value="colors">
<TabsContent value="colors" className="mt-1 size-full overflow-hidden">
<ScrollArea className="h-full px-4">
<ControlSection title="Primary Colors" id="primary-colors" expanded>
<ColorPicker
color={currentStyles.primary}
@@ -292,10 +307,12 @@ const ThemeControlPanel = ({
label="Sidebar Ring"
/>
</ControlSection>
</TabsContent>
</ScrollArea>
</TabsContent>
<TabsContent value="typography" className="flex flex-col gap-4">
<div className="bg-muted/50 mb-2 flex items-start gap-2.5 rounded-md border p-3">
<TabsContent value="typography" className="mt-1 size-full overflow-hidden">
<ScrollArea className="h-full px-4">
<div className="bg-muted/50 mb-4 flex items-start gap-2.5 rounded-md border p-3">
<AlertCircle className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="text-muted-foreground text-sm">
<p>
@@ -365,9 +382,11 @@ const ThemeControlPanel = ({
label="Letter Spacing"
/>
</ControlSection>
</TabsContent>
</ScrollArea>
</TabsContent>
<TabsContent value="other">
<TabsContent value="other" className="mt-1 size-full overflow-hidden">
<ScrollArea className="h-full px-4">
<ControlSection title="HSL Adjustments" expanded>
<HslAdjustmentControls />
</ControlSection>
@@ -415,8 +434,12 @@ const ThemeControlPanel = ({
}}
/>
</ControlSection>
</TabsContent>
</ScrollArea>
</ScrollArea>
</TabsContent>
<TabsContent value="ai" className="mt-1 size-full overflow-hidden">
<AIInterface />
</TabsContent>
</Tabs>
</div>
</>
+23 -25
View File
@@ -1,32 +1,32 @@
import { Button } from "../ui/button";
import { X, Check } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { Separator } from "../ui/separator";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useThemeActions } from "@/hooks/use-theme-actions";
import { useEditorStore } from "@/store/editor-store";
import { Theme } from "@/types/theme";
import { ThemeSaveDialog } from "./theme-save-dialog";
import { Check, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ThemeSaveDialog } from "./theme-save-dialog";
interface ThemeEditActionsProps {
theme: Theme;
disabled?: boolean;
}
const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme, disabled = false }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { updateTheme } = useThemeActions();
const { themeState, applyThemePreset } = useEditorStore();
const [isNameDialogOpen, setIsNameDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const mainEditorUrl = `/editor/theme?${searchParams}`;
const handleThemeEditCancel = () => {
router.push("/editor/theme");
// Keep the current search params for tab persistence
router.push(mainEditorUrl);
applyThemePreset(themeState?.preset || "default");
};
@@ -61,7 +61,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
if (result) {
setIsNameDialogOpen(false);
router.push("/editor/theme");
router.push(mainEditorUrl);
applyThemePreset(result?.id || themeState?.preset || "default");
} else {
console.error("Failed to update theme");
@@ -74,20 +74,16 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
return (
<>
<div className="flex items-center bg-card/80 text-card-foreground">
<div className="flex flex-1 items-center gap-2 min-h-14 px-4">
<div className="flex items-center gap-2 animate-pulse">
<div className="bg-card/80 text-card-foreground flex items-center">
<div className="flex min-h-14 flex-1 items-center gap-2 px-4">
<div className="flex animate-pulse items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span className="text-sm font-medium text-card-foreground/60">
Editing
</span>
<span className="text-card-foreground/60 text-sm font-medium">Editing</span>
</div>
<span className="text-sm font-semibold px-2 truncate max-w-56">
{theme.name}
</span>
<span className="max-w-56 truncate px-2 text-sm font-semibold">{theme.name}</span>
</div>
<Separator orientation="vertical" className="h-8 bg-border" />
<Separator orientation="vertical" className="bg-border h-8" />
<TooltipProvider>
<Tooltip>
@@ -97,6 +93,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
size="icon"
className="size-14 shrink-0 rounded-none"
onClick={handleThemeEditCancel}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
@@ -105,7 +102,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
</Tooltip>
</TooltipProvider>
<Separator orientation="vertical" className="h-8 bg-border" />
<Separator orientation="vertical" className="bg-border h-8" />
<TooltipProvider>
<Tooltip>
@@ -115,6 +112,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
size="icon"
className="size-14 shrink-0 rounded-none"
onClick={handleThemeEditSave}
disabled={disabled}
>
<Check className="h-4 w-4" />
</Button>
@@ -433,6 +433,7 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
filteredPresets={filteredPresets}
currentPresetName={currentPresetName || "default"}
className={className}
disabled={props.disabled}
/>
)}
</div>
+93 -48
View File
@@ -1,31 +1,40 @@
"use client";
import { ThemeEditorPreviewProps } from "@/types/theme";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import ColorPreview from "./theme-preview/color-preview";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
import { lazy } from "react";
import ShadcnBlocksLogo from "@/assets/shadcnblocks.svg";
import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import { Maximize, Minimize, Moon, Sun } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { cn } from "@/lib/utils";
import { useTheme } from "@/components/theme-provider";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ThemeEditorPreviewProps } from "@/types/theme";
import { Maximize, Minimize, Moon, MoreVertical, Sun } from "lucide-react";
import Link from "next/link";
import ShadcnBlocksLogo from "@/assets/shadcnblocks.svg";
import { lazy, useState } from "react";
import { HorizontalScrollArea } from "../horizontal-scroll-area";
import { TooltipWrapper } from "../tooltip-wrapper";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import ColorPreview from "./theme-preview/color-preview";
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
const DemoCards = lazy(() => import("@/components/examples/demo-cards"));
const DemoCards = lazy(() => import("@/components/examples/cards"));
const DemoMail = lazy(() => import("@/components/examples/mail"));
const DemoTasks = lazy(() => import("@/components/examples/tasks"));
const DemoMusic = lazy(() => import("@/components/examples/music"));
const DemoDashboard = lazy(() => import("@/components/examples/dashboard"));
const DemoPricing = lazy(() => import("@/components/examples/pricing/pricing"));
const TypographyDemo = lazy(() => import("@/components/examples/typography/typography-demo"));
const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) => {
const { isFullscreen, toggleFullscreen } = useFullscreen();
const { theme, toggleTheme } = useTheme();
const [activeTab, setActiveTab] = useState("cards");
if (!styles || !styles[currentMode]) {
return null;
@@ -36,6 +45,10 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
toggleTheme({ x, y });
};
const handleTabChange = (value: string) => {
setActiveTab(value);
};
return (
<>
<div
@@ -44,63 +57,89 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
isFullscreen && "bg-background fixed inset-0 z-50"
)}
>
<Tabs defaultValue="cards" className="flex flex-1 flex-col overflow-hidden">
<div className="mt-2 flex items-center justify-between px-4">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col overflow-hidden"
>
<HorizontalScrollArea className="mt-2 mb-1 flex w-full items-center justify-between px-4">
<TabsList className="bg-background text-muted-foreground inline-flex w-fit items-center justify-center rounded-full px-0">
<TabsTriggerPill value="cards">Cards</TabsTriggerPill>
<div className="hidden md:flex">
<TabsTriggerPill value="dashboard">Dashboard</TabsTriggerPill>
<TabsTriggerPill value="pricing">Pricing</TabsTriggerPill>
<TabsTriggerPill value="mail">Mail</TabsTriggerPill>
<TabsTriggerPill value="tasks">Tasks</TabsTriggerPill>
<TabsTriggerPill value="music">Music</TabsTriggerPill>
</div>
<TabsTriggerPill value="pricing">Pricing</TabsTriggerPill>
<TabsTriggerPill value="colors">Color Palette</TabsTriggerPill>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TooltipWrapper label="More previews" asChild>
<Button variant="ghost" size="icon">
<MoreVertical />
</Button>
</TooltipWrapper>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleTabChange("typography")}>
Typography
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleTabChange("tasks")}
className="hidden md:flex"
>
Tasks
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleTabChange("music")}
className="hidden md:flex"
>
Music
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TabsList>
<div className="flex items-center gap-0">
{isFullscreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleThemeToggle}
className="group h-8"
>
{theme === "light" ? (
<Sun className="size-4 transition-all group-hover:scale-120" />
) : (
<Moon className="size-4 transition-all group-hover:scale-120" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle Theme</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<TooltipWrapper label="Toggle Theme" asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
className="group h-8"
onClick={handleThemeToggle}
className="group size-8"
>
{isFullscreen ? (
<Minimize className="size-4 transition-all group-hover:scale-120" />
{theme === "light" ? (
<Sun className="transition-all group-hover:scale-120" />
) : (
<Maximize className="size-4 transition-all group-hover:scale-120" />
<Moon className="transition-all group-hover:scale-120" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isFullscreen ? "Exit full screen" : "Full screen"}</TooltipContent>
</Tooltip>
</TooltipWrapper>
)}
<TooltipWrapper
label={isFullscreen ? "Exit full screen" : "Full screen"}
className="hidden md:inline-flex"
>
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
className="group size-8"
>
{isFullscreen ? (
<Minimize className="transition-all group-hover:scale-120" />
) : (
<Maximize className="transition-all group-hover:scale-120" />
)}
</Button>
</TooltipWrapper>
</div>
</div>
</HorizontalScrollArea>
<ScrollArea className="relative m-4 mt-2 flex flex-1 flex-col overflow-hidden rounded-lg border">
<ScrollArea className="relative m-4 mt-1 flex flex-1 flex-col overflow-hidden rounded-lg border">
<div className="flex h-full flex-1 flex-col">
<TabsContent value="cards" className="my-4 h-full space-y-6 px-4">
<TabsContent value="cards" className="m-0 h-full space-y-6 p-2 md:p-4">
<ExamplesPreviewContainer>
<DemoCards />
</ExamplesPreviewContainer>
@@ -157,6 +196,12 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
</ExamplesPreviewContainer>
</TabsContent>
<TabsContent value="typography" className="space-y-6 p-4">
<ExamplesPreviewContainer>
<TypographyDemo />
</ExamplesPreviewContainer>
</TabsContent>
<TabsContent value="colors" className="space-y-6 p-4">
<ColorPreview styles={styles} currentMode={currentMode} />
</TabsContent>
+109 -98
View File
@@ -1,3 +1,5 @@
import { CopyButton } from "@/components/copy-button";
import { Separator } from "@/components/ui/separator";
import { ThemeEditorPreviewProps } from "@/types/theme";
interface ColorPreviewProps {
@@ -5,18 +7,21 @@ interface ColorPreviewProps {
currentMode: ThemeEditorPreviewProps["currentMode"];
}
const renderColorPreview = (label: string, color: string) => (
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-md border"
style={{ backgroundColor: color }}
/>
<div className="flex-1">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{color}</p>
function ColorPreviewItem({ label, color }: { label: string; color: string }) {
return (
<div className="group/color-preview hover:bg-muted relative flex items-center gap-4 rounded-md transition-colors">
<div className="h-12 w-12 rounded-md border" style={{ backgroundColor: color }} />
<div className="flex-1">
<p className="text-sm font-medium @max-3xl:text-xs">{label}</p>
<p className="text-muted-foreground text-xs">{color}</p>
</div>
<div className="absolute right-1 rounded-md opacity-0 transition-opacity group-hover/color-preview:opacity-100">
<CopyButton textToCopy={color} />
</div>
</div>
</div>
);
);
}
const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => {
if (!styles || !styles[currentMode]) {
@@ -24,133 +29,139 @@ const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => {
}
return (
<div className="grid grid-cols-1 gap-8">
<div className="@container grid grid-cols-1 gap-4 md:gap-8">
{/* Primary Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">Primary Theme Colors</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Background", styles[currentMode].background)}
{renderColorPreview("Foreground", styles[currentMode].foreground)}
{renderColorPreview("Primary", styles[currentMode].primary)}
{renderColorPreview(
"Primary Foreground",
styles[currentMode]["primary-foreground"]
)}
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">Primary Theme Colors</h3>
<div className="@6xl grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Background" color={styles[currentMode].background} />
<ColorPreviewItem label="Foreground" color={styles[currentMode].foreground} />
<ColorPreviewItem label="Primary" color={styles[currentMode].primary} />
<ColorPreviewItem
label="Primary Foreground"
color={styles[currentMode]["primary-foreground"]}
/>
</div>
</div>
<Separator />
{/* Secondary & Accent Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
Secondary & Accent Colors
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Secondary", styles[currentMode].secondary)}
{renderColorPreview(
"Secondary Foreground",
styles[currentMode]["secondary-foreground"]
)}
{renderColorPreview("Accent", styles[currentMode].accent)}
{renderColorPreview(
"Accent Foreground",
styles[currentMode]["accent-foreground"]
)}
<div className="@6xl grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Secondary" color={styles[currentMode].secondary} />
<ColorPreviewItem
label="Secondary Foreground"
color={styles[currentMode]["secondary-foreground"]}
/>
<ColorPreviewItem label="Accent" color={styles[currentMode].accent} />
<ColorPreviewItem
label="Accent Foreground"
color={styles[currentMode]["accent-foreground"]}
/>
</div>
</div>
<Separator />
{/* UI Component Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">UI Component Colors</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Card", styles[currentMode].card)}
{renderColorPreview(
"Card Foreground",
styles[currentMode]["card-foreground"]
)}
{renderColorPreview("Popover", styles[currentMode].popover)}
{renderColorPreview(
"Popover Foreground",
styles[currentMode]["popover-foreground"]
)}
{renderColorPreview("Muted", styles[currentMode].muted)}
{renderColorPreview(
"Muted Foreground",
styles[currentMode]["muted-foreground"]
)}
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">UI Component Colors</h3>
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Card" color={styles[currentMode].card} />
<ColorPreviewItem
label="Card Foreground"
color={styles[currentMode]["card-foreground"]}
/>
<ColorPreviewItem label="Popover" color={styles[currentMode].popover} />
<ColorPreviewItem
label="Popover Foreground"
color={styles[currentMode]["popover-foreground"]}
/>
<ColorPreviewItem label="Muted" color={styles[currentMode].muted} />
<ColorPreviewItem
label="Muted Foreground"
color={styles[currentMode]["muted-foreground"]}
/>
</div>
</div>
<Separator />
{/* Utility & Form Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">Utility & Form Colors</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Border", styles[currentMode].border)}
{renderColorPreview("Input", styles[currentMode].input)}
{renderColorPreview("Ring", styles[currentMode].ring)}
{renderColorPreview("Radius", styles[currentMode].radius)}
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">Utility & Form Colors</h3>
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Border" color={styles[currentMode].border} />
<ColorPreviewItem label="Input" color={styles[currentMode].input} />
<ColorPreviewItem label="Ring" color={styles[currentMode].ring} />
<ColorPreviewItem label="Radius" color={styles[currentMode].radius} />
</div>
</div>
<Separator />
{/* Status & Feedback Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
Status & Feedback Colors
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Destructive", styles[currentMode].destructive)}
{renderColorPreview(
"Destructive Foreground",
styles[currentMode]["destructive-foreground"]
)}
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Destructive" color={styles[currentMode].destructive} />
<ColorPreviewItem
label="Destructive Foreground"
color={styles[currentMode]["destructive-foreground"]}
/>
</div>
</div>
<Separator />
{/* Chart & Data Visualization Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
Chart & Visualization Colors
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Chart 1", styles[currentMode]["chart-1"])}
{renderColorPreview("Chart 2", styles[currentMode]["chart-2"])}
{renderColorPreview("Chart 3", styles[currentMode]["chart-3"])}
{renderColorPreview("Chart 4", styles[currentMode]["chart-4"])}
{renderColorPreview("Chart 5", styles[currentMode]["chart-5"])}
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Chart 1" color={styles[currentMode]["chart-1"]} />
<ColorPreviewItem label="Chart 2" color={styles[currentMode]["chart-2"]} />
<ColorPreviewItem label="Chart 3" color={styles[currentMode]["chart-3"]} />
<ColorPreviewItem label="Chart 4" color={styles[currentMode]["chart-4"]} />
<ColorPreviewItem label="Chart 5" color={styles[currentMode]["chart-5"]} />
</div>
</div>
<Separator />
{/* Sidebar Colors */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
Sidebar & Navigation Colors
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderColorPreview("Sidebar Background", styles[currentMode].sidebar)}
{renderColorPreview(
"Sidebar Foreground",
styles[currentMode]["sidebar-foreground"]
)}
{renderColorPreview(
"Sidebar Primary",
styles[currentMode]["sidebar-primary"]
)}
{renderColorPreview(
"Sidebar Primary Foreground",
styles[currentMode]["sidebar-primary-foreground"]
)}
{renderColorPreview(
"Sidebar Accent",
styles[currentMode]["sidebar-accent"]
)}
{renderColorPreview(
"Sidebar Accent Foreground",
styles[currentMode]["sidebar-accent-foreground"]
)}
{renderColorPreview(
"Sidebar Border",
styles[currentMode]["sidebar-border"]
)}
{renderColorPreview("Sidebar Ring", styles[currentMode]["sidebar-ring"])}
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
<ColorPreviewItem label="Sidebar Background" color={styles[currentMode].sidebar} />
<ColorPreviewItem
label="Sidebar Foreground"
color={styles[currentMode]["sidebar-foreground"]}
/>
<ColorPreviewItem
label="Sidebar Primary"
color={styles[currentMode]["sidebar-primary"]}
/>
<ColorPreviewItem
label="Sidebar Primary Foreground"
color={styles[currentMode]["sidebar-primary-foreground"]}
/>
<ColorPreviewItem label="Sidebar Accent" color={styles[currentMode]["sidebar-accent"]} />
<ColorPreviewItem
label="Sidebar Accent Foreground"
color={styles[currentMode]["sidebar-accent-foreground"]}
/>
<ColorPreviewItem label="Sidebar Border" color={styles[currentMode]["sidebar-border"]} />
<ColorPreviewItem label="Sidebar Ring" color={styles[currentMode]["sidebar-ring"]} />
</div>
</div>
</div>
@@ -24,7 +24,7 @@ const ExamplesPreviewContainer = ({
}) => {
return (
<div className={cn("space-y-6", className)}>
<div className="mt-0 h-full space-y-6">
<div className="@container mt-0 h-full w-full space-y-6">
<Suspense fallback={<LoadingSkeleton />}>{children}</Suspense>
</div>
</div>
@@ -1,10 +1,14 @@
import { TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { TabsTriggerProps } from "@radix-ui/react-tabs";
const TabsTriggerPill = ({ children, ...props }: TabsTriggerProps) => {
const TabsTriggerPill = ({ children, className, ...props }: TabsTriggerProps) => {
return (
<TabsTrigger
className="inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground hover:text-muted-foreground/70"
className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground hover:text-muted-foreground/70 inline-flex items-center justify-center rounded-full px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { MinusIcon, PlusIcon } from "lucide-react";
import * as React from "react";
import { Bar, BarChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
const data = [
{
goal: 400,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 239,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 349,
},
];
const chartConfig = {
goal: {
label: "Goal",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function CardsActivityGoal() {
const [goal, setGoal] = React.useState(350);
function onClick(adjustment: number) {
setGoal(Math.max(200, Math.min(400, goal + adjustment)));
}
return (
<Card className="flex h-full flex-col">
<CardHeader>
<CardTitle>Move Goal</CardTitle>
<CardDescription>Set your daily activity goal.</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="icon"
className="size-7 rounded-full"
onClick={() => onClick(-10)}
disabled={goal <= 200}
>
<MinusIcon />
<span className="sr-only">Decrease</span>
</Button>
<div className="text-center">
<div className="text-4xl font-bold tracking-tighter tabular-nums">{goal}</div>
<div className="text-muted-foreground text-xs uppercase">Calories/day</div>
</div>
<Button
variant="outline"
size="icon"
className="size-7 rounded-full"
onClick={() => onClick(10)}
disabled={goal >= 400}
>
<PlusIcon />
<span className="sr-only">Increase</span>
</Button>
</div>
<ChartContainer config={chartConfig} className="max-h-20 w-full">
<BarChart data={data}>
<Bar dataKey="goal" radius={4} fill="var(--color-goal)" />
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter>
<Button className="w-full" variant="secondary">
Set Goal
</Button>
</CardFooter>
</Card>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { addDays } from "date-fns";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent } from "@/components/ui/card";
const start = new Date(2025, 5, 5);
export function CardsCalendar() {
return (
<Card className="hidden p-0 @2xl:flex">
<CardContent className="p-0">
<Calendar
numberOfMonths={1}
mode="range"
defaultMonth={start}
selected={{
from: start,
to: addDays(start, 8),
}}
/>
</CardContent>
</Card>
);
}
+42 -47
View File
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { Check, Plus, Send } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
@@ -22,12 +22,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, CheckIcon, PlusIcon } from "lucide-react";
const users = [
{
@@ -59,7 +56,7 @@ const users = [
type User = (typeof users)[number];
export function DemoChat() {
export function CardsChat() {
const [open, setOpen] = React.useState(false);
const [selectedUsers, setSelectedUsers] = React.useState<User[]>([]);
@@ -88,14 +85,14 @@ export function DemoChat() {
<>
<Card>
<CardHeader className="flex flex-row items-center">
<div className="flex items-center space-x-4">
<Avatar>
<div className="flex items-center gap-4">
<Avatar className="border">
<AvatarImage src="/avatars/01.png" alt="Image" />
<AvatarFallback>OM</AvatarFallback>
<AvatarFallback>S</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">Sofia Davis</p>
<p className="text-sm text-muted-foreground">m@example.com</p>
<div className="flex flex-col gap-0.5">
<p className="text-sm leading-none font-medium">Sofia Davis</p>
<p className="text-muted-foreground text-xs">m@example.com</p>
</div>
</div>
<TooltipProvider delayDuration={0}>
@@ -103,11 +100,11 @@ export function DemoChat() {
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="ml-auto rounded-full"
variant="secondary"
className="ml-auto size-8 rounded-full"
onClick={() => setOpen(true)}
>
<Plus />
<PlusIcon />
<span className="sr-only">New message</span>
</Button>
</TooltipTrigger>
@@ -116,15 +113,15 @@ export function DemoChat() {
</TooltipProvider>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-col gap-4">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex w-max max-w-[75%] flex-col gap-2 rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted",
? "bg-primary text-primary-foreground ml-auto"
: "bg-muted"
)}
>
{message.content}
@@ -146,26 +143,31 @@ export function DemoChat() {
]);
setInput("");
}}
className="flex w-full items-center space-x-2"
className="relative w-full"
>
<Input
id="message"
placeholder="Type your message..."
className="flex-1"
className="flex-1 pr-10"
autoComplete="off"
value={input}
onChange={(event) => setInput(event.target.value)}
/>
<Button type="submit" size="icon" disabled={inputLength === 0}>
<Send />
<Button
type="submit"
size="icon"
className="absolute top-1/2 right-2 size-6 -translate-y-1/2 rounded-full"
disabled={inputLength === 0}
>
<ArrowUpIcon className="size-3.5" />
<span className="sr-only">Send</span>
</Button>
</form>
</CardFooter>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-0 p-0 outline-hidden">
<DialogHeader className="px-4 pb-4 pt-5">
<DialogContent className="gap-0 p-0 outline-none">
<DialogHeader className="px-4 pt-5 pb-4">
<DialogTitle>New message</DialogTitle>
<DialogDescription>
Invite a user to this thread. This will create a new group message.
@@ -175,63 +177,56 @@ export function DemoChat() {
<CommandInput placeholder="Search user..." />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup className="p-2">
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.email}
className="flex items-center px-2"
data-active={selectedUsers.includes(user)}
className="data-[active=true]:opacity-50"
onSelect={() => {
if (selectedUsers.includes(user)) {
return setSelectedUsers(
selectedUsers.filter(
(selectedUser) => selectedUser !== user,
),
selectedUsers.filter((selectedUser) => selectedUser !== user)
);
}
return setSelectedUsers(
[...users].filter((u) =>
[...selectedUsers, user].includes(u),
),
[...users].filter((u) => [...selectedUsers, user].includes(u))
);
}}
>
<Avatar>
<Avatar className="border">
<AvatarImage src={user.avatar} alt="Image" />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
<div className="ml-2">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
<p className="text-sm leading-none font-medium">{user.name}</p>
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
{selectedUsers.includes(user) ? (
<Check className="ml-auto flex h-5 w-5 text-primary" />
<CheckIcon className="text-primary ml-auto flex size-4" />
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<DialogFooter className="flex items-center border-t p-4 sm:justify-between">
<DialogFooter className="flex items-center border-t p-4 @2xl:justify-between">
{selectedUsers.length > 0 ? (
<div className="flex -space-x-2 overflow-hidden">
{selectedUsers.map((user) => (
<Avatar
key={user.email}
className="inline-block border-2 border-background"
>
<Avatar key={user.email} className="inline-block border">
<AvatarImage src={user.avatar} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Select users to add to this thread.
</p>
<p className="text-muted-foreground text-sm">Select users to add to this thread.</p>
)}
<Button
disabled={selectedUsers.length < 2}
size="sm"
onClick={() => {
setOpen(false);
}}
+12 -20
View File
@@ -1,3 +1,5 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -10,7 +12,7 @@ import {
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export function DemoCookieSettings() {
export function CardsCookieSettings() {
return (
<Card>
<CardHeader>
@@ -18,33 +20,23 @@ export function DemoCookieSettings() {
<CardDescription>Manage your cookie settings here.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="necessary" className="flex flex-col space-y-1">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="necessary" className="flex flex-col items-start">
<span>Strictly Necessary</span>
<span className="font-normal leading-snug text-muted-foreground">
These cookies are essential in order to use the website and use its
features.
<span className="text-muted-foreground leading-snug font-normal">
These cookies are essential in order to use the website and use its features.
</span>
</Label>
<Switch id="necessary" defaultChecked />
<Switch id="necessary" defaultChecked aria-label="Necessary" />
</div>
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="functional" className="flex flex-col space-y-1">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="functional" className="flex flex-col items-start">
<span>Functional Cookies</span>
<span className="font-normal leading-snug text-muted-foreground">
<span className="text-muted-foreground leading-snug font-normal">
These cookies allow the website to provide personalized functionality.
</span>
</Label>
<Switch id="functional" />
</div>
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="performance" className="flex flex-col space-y-1">
<span>Performance Cookies</span>
<span className="font-normal leading-snug text-muted-foreground">
These cookies help to improve the performance of the website.
</span>
</Label>
<Switch id="performance" />
<Switch id="functional" aria-label="Functional" />
</div>
</CardContent>
<CardFooter>
+26 -19
View File
@@ -1,4 +1,5 @@
import { Icons } from "@/components/icons";
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -11,23 +12,31 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function DemoCreateAccount() {
export function CardsCreateAccount() {
return (
<Card>
<CardHeader className="space-y-1">
<CardHeader>
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>
Enter your email below to create your account
</CardDescription>
<CardDescription>Enter your email below to create your account</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-6">
<Button variant="outline">
<Icons.gitHub className="mr-2 h-4 w-4" />
Github
<svg viewBox="0 0 438.549 438.549">
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>
GitHub
</Button>
<Button variant="outline">
<Icons.google className="mr-2 h-4 w-4" />
<svg role="img" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
Google
</Button>
</div>
@@ -36,18 +45,16 @@ export function DemoCreateAccount() {
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
<span className="bg-card text-muted-foreground px-2">Or continue with</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" />
<div className="flex flex-col gap-3">
<Label htmlFor="email-create-account">Email</Label>
<Input id="email-create-account" type="email" placeholder="m@example.com" />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" />
<div className="flex flex-col gap-3">
<Label htmlFor="password-create-account">Password</Label>
<Input id="password-create-account" type="password" />
</div>
</CardContent>
<CardFooter>
@@ -1,58 +1,65 @@
import * as React from "react";
"use client";
import { addDays, format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";
import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function DatePickerWithRange({
className,
}: React.HTMLAttributes<HTMLDivElement>) {
export function DatePickerWithRange({ className }: React.HTMLAttributes<HTMLDivElement>) {
const [date, setDate] = React.useState<DateRange | undefined>({
from: new Date(2022, 0, 20),
to: addDays(new Date(2022, 0, 20), 20),
});
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!date && "text-muted-foreground",
)}
>
<CalendarIcon />
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} - {format(date.to, "LLL dd, y")}
</>
<Card className={cn("grid gap-2", className)}>
<CardHeader>
<CardTitle>Date picker with range</CardTitle>
<CardDescription>Select a date range.</CardDescription>
</CardHeader>
<CardContent>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon />
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} - {format(date.to, "LLL dd, y")}
</>
) : (
format(date.from, "LLL dd, y")
)
) : (
format(date.from, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</CardContent>
</Card>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import DatePickerWithRange from "./date-picker-with-range";
export function DemoDatePicker() {
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="date" className="shrink-0">
Pick a date
</Label>
<DatePickerWithRange className="[&>button]:w-[260px]" />
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,123 @@
"use client";
import { CartesianGrid, Line, LineChart, XAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
const data = [
{
average: 400,
today: 240,
day: "Monday",
},
{
average: 300,
today: 139,
day: "Tuesday",
},
{
average: 200,
today: 980,
day: "Wednesday",
},
{
average: 278,
today: 390,
day: "Thursday",
},
{
average: 189,
today: 480,
day: "Friday",
},
{
average: 239,
today: 380,
day: "Saturday",
},
{
average: 349,
today: 430,
day: "Sunday",
},
];
const chartConfig = {
today: {
label: "Today",
color: "var(--primary)",
},
average: {
label: "Average",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function CardsExerciseMinutes() {
return (
<Card>
<CardHeader>
<CardTitle>Exercise Minutes</CardTitle>
<CardDescription>
Your exercise minutes are ahead of where you normally are.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="w-full @3xl:h-[200px]">
<LineChart
accessibilityLayer
data={data}
margin={{
top: 5,
right: 10,
left: 16,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="day"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
/>
<Line
type="monotone"
dataKey="today"
strokeWidth={2}
stroke="var(--color-today)"
dot={{
fill: "var(--color-today)",
}}
activeDot={{
r: 5,
}}
/>
<Line
type="monotone"
strokeWidth={2}
dataKey="average"
stroke="var(--color-average)"
strokeOpacity={0.5}
dot={{
fill: "var(--color-average)",
opacity: 0.5,
}}
activeDot={{
r: 5,
}}
/>
<ChartTooltip content={<ChartTooltipContent />} />
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -1,52 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function DemoFontShowcase() {
return (
<Card>
<CardHeader>
<CardTitle>Font Showcase</CardTitle>
<CardDescription>View theme fonts in different styles</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div>
<h3 className="text-lg font-semibold mb-2">Sans-Serif</h3>
<div className="font-sans space-y-1">
<div className="text-xl font-light">Light Weight Text</div>
<div className="text-xl">Regular Weight Text</div>
<div className="text-xl font-medium">Medium Weight Text</div>
<div className="text-xl font-semibold">Semibold Weight Text</div>
<div className="text-xl font-bold">Bold Weight Text</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Serif</h3>
<div className="font-serif space-y-1">
<div className="text-xl font-light">Light Weight Text</div>
<div className="text-xl">Regular Weight Text</div>
<div className="text-xl font-medium">Medium Weight Text</div>
<div className="text-xl font-semibold">Semibold Weight Text</div>
<div className="text-xl font-bold">Bold Weight Text</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Monospace</h3>
<div className="font-mono space-y-1">
<div className="text-xl font-light">Light Weight Text</div>
<div className="text-xl">Regular Weight Text</div>
<div className="text-xl font-medium">Medium Weight Text</div>
<div className="text-xl font-semibold">Semibold Weight Text</div>
<div className="text-xl font-bold">Bold Weight Text</div>
</div>
</div>
</CardContent>
</Card>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
const plans = [
{
id: "starter",
name: "Starter Plan",
description: "Perfect for small businesses.",
price: "$10",
},
{
id: "pro",
name: "Pro Plan",
description: "More features and storage.",
price: "$20",
},
] as const;
export function CardsForms() {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Upgrade your subscription</CardTitle>
<CardDescription className="text-balance">
You are currently on the free plan. Upgrade to the pro plan to get access to all features.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 @3xl:flex-row">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Evil Rabbit" />
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" placeholder="example@acme.com" />
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-number">Card Number</Label>
<div className="grid grid-cols-2 gap-3 @3xl:grid-cols-[1fr_80px_60px]">
<Input
id="card-number"
placeholder="1234 1234 1234 1234"
className="col-span-2 @3xl:col-span-1"
/>
<Input id="card-number-expiry" placeholder="MM/YY" />
<Input id="card-number-cvc" placeholder="CVC" />
</div>
</div>
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Plan</legend>
<p className="text-muted-foreground text-sm">
Select the plan that best fits your needs.
</p>
<RadioGroup defaultValue="starter" className="grid gap-3 @3xl:grid-cols-2">
{plans.map((plan) => (
<Label
className="has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-input/20 flex items-start gap-3 rounded-lg border p-3"
key={plan.id}
>
<RadioGroupItem
value={plan.id}
id={plan.name}
className="data-[state=checked]:border-primary"
/>
<div className="grid gap-1 font-normal">
<div className="font-medium">{plan.name}</div>
<div className="text-muted-foreground text-xs leading-snug text-balance">
{plan.description}
</div>
</div>
</Label>
))}
</RadioGroup>
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea id="notes" placeholder="Enter notes" />
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Checkbox id="terms" />
<Label htmlFor="terms" className="font-normal">
I agree to the terms and conditions
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="newsletter" defaultChecked />
<Label htmlFor="newsletter" className="font-normal">
Allow us to send you emails
</Label>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" size="sm">
Cancel
</Button>
<Button size="sm">Upgrade Plan</Button>
</CardFooter>
</Card>
);
}
+22 -60
View File
@@ -1,73 +1,35 @@
import { ChevronDown, Circle, Plus, Star } from "lucide-react";
"use client";
import { Circle, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export function DemoGithub() {
export function GithubCard() {
return (
<Card>
<CardHeader className="grid grid-cols-[1fr_110px] items-start gap-4 space-y-0">
<div className="space-y-1">
<CardTitle>tweakcn</CardTitle>
<CardDescription>
A visual editor for shadcn/ui components with beautiful themes.
Accessible. Customizable. Open Source.
</CardDescription>
</div>
<div className="flex items-center space-x-1 rounded-md bg-secondary text-secondary-foreground">
<Link href="https://github.com/jnsahaj/tweakcn">
<Button variant="secondary" className="px-3 shadow-none">
<Star />
Star
</Button>
</Link>
<Separator orientation="vertical" className="h-[20px]" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" className="px-2 shadow-none">
<ChevronDown className="text-secondary-foreground" />
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>tweakcn</CardTitle>
<CardDescription>
A visual editor for shadcn/ui components with beautiful themes. Accessible.
Customizable. Open Source.
</CardDescription>
</div>
<div className="bg-secondary text-secondary-foreground flex min-w-20 shrink-0 items-center space-x-1 rounded-md">
<Link href="https://github.com/jnsahaj/tweakcn">
<Button variant="secondary" className="flex items-center gap-2 px-3 shadow-none">
<Star />
Star
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
alignOffset={-5}
className="w-[200px]"
forceMount
>
<DropdownMenuLabel>Suggested Lists</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>
Future Ideas
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>My Stack</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Inspiration</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Plus /> Create List
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Link>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex space-x-4 text-sm text-muted-foreground">
<div className="text-muted-foreground flex space-x-4 text-sm">
<div className="flex items-center">
<Circle className="mr-1 h-3 w-3 fill-sky-400 text-sky-400" />
TypeScript
+72
View File
@@ -0,0 +1,72 @@
import { CardsActivityGoal } from "@/components/examples/cards/activity-goal";
import { CardsCalendar } from "@/components/examples/cards/calendar";
import { CardsChat } from "@/components/examples/cards/chat";
import { CardsCookieSettings } from "@/components/examples/cards/cookie-settings";
import { CardsCreateAccount } from "@/components/examples/cards/create-account";
import { CardsExerciseMinutes } from "@/components/examples/cards/exercise-minutes";
import { CardsForms } from "@/components/examples/cards/forms";
import { CardsPayments } from "@/components/examples/cards/payments";
import { CardsReportIssue } from "@/components/examples/cards/report-issue";
import { CardsShare } from "@/components/examples/cards/share";
import { CardsStats } from "@/components/examples/cards/stats";
import { CardsTeamMembers } from "@/components/examples/cards/team-members";
import { DatePickerWithRange } from "./date-picker-with-range";
import { GithubCard } from "./github-card";
export default function CardsDemo() {
return (
<div className="@3xl:grids-col-2 grid **:data-[slot=card]:shadow-none @3xl:gap-4 @5xl:grid-cols-10 @7xl:grid-cols-11">
<div className="grid gap-4 @5xl:col-span-4 @7xl:col-span-6">
<CardsStats />
<div className="grid gap-1 @2xl:grid-cols-[auto_1fr] @3xl:hidden">
<CardsCalendar />
<div className="@2xl:pt-0 @2xl:pl-3 @7xl:pl-4">
<CardsActivityGoal />
</div>
<div className="pt-3 @2xl:col-span-2 @7xl:pt-4">
<CardsExerciseMinutes />
</div>
</div>
<div className="grid gap-4 @3xl:grid-cols-2 @5xl:grid-cols-1 @7xl:grid-cols-2">
<div className="flex flex-col gap-4">
<CardsForms />
<CardsTeamMembers />
<CardsCookieSettings />
<div className="hidden flex-col gap-4 @7xl:flex">
<GithubCard />
<DatePickerWithRange />
</div>
</div>
<div className="flex flex-col gap-4 pb-4">
<CardsCreateAccount />
<CardsChat />
<GithubCard />
<DatePickerWithRange />
<div className="hidden @7xl:block">
<CardsReportIssue />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 @5xl:col-span-6 @7xl:col-span-5">
<div className="hidden gap-1 @2xl:grid-cols-[auto_1fr] @3xl:grid">
<CardsCalendar />
<div className="pt-3 @2xl:pt-0 @2xl:pl-3 @7xl:pl-4">
<CardsActivityGoal />
</div>
<div className="pt-3 @2xl:col-span-2 @7xl:pt-3">
<CardsExerciseMinutes />
</div>
</div>
<div className="hidden @3xl:block">
<CardsPayments />
</div>
<CardsShare />
<div className="@7xl:hidden">
<CardsReportIssue />
</div>
</div>
</div>
);
}
@@ -1,47 +0,0 @@
import { Bell, EyeOff, User } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function DemoNotifications() {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle>Notifications</CardTitle>
<CardDescription>Choose what you want to be notified about.</CardDescription>
</CardHeader>
<CardContent className="grid gap-1">
<div className="-mx-2 flex items-start space-x-4 rounded-md p-2 transition-all hover:bg-accent hover:text-accent-foreground">
<Bell className="mt-px h-5 w-5" />
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Everything</p>
<p className="text-sm text-muted-foreground">
Email digest, mentions & all activity.
</p>
</div>
</div>
<div className="-mx-2 flex items-start space-x-4 rounded-md bg-accent p-2 text-accent-foreground transition-all">
<User className="mt-px h-5 w-5" />
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Available</p>
<p className="text-sm">Only mentions and comments.</p>
</div>
</div>
<div className="-mx-2 flex items-start space-x-4 rounded-md p-2 transition-all hover:bg-accent hover:text-accent-foreground">
<EyeOff className="mt-px h-5 w-5" />
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Ignoring</p>
<p className="text-sm text-muted-foreground">
Turn off all notifications.
</p>
</div>
</div>
</CardContent>
</Card>
);
}
+52 -54
View File
@@ -1,4 +1,5 @@
import { Icons } from "@/components/icons";
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -19,71 +20,68 @@ import {
SelectValue,
} from "@/components/ui/select";
export function DemoPaymentMethod() {
const plans = [
{
id: "starter",
name: "Starter Plan",
description: "Perfect for small businesses.",
price: "$10",
},
{
id: "pro",
name: "Pro Plan",
description: "Advanced features with more storage.",
price: "$20",
},
] as const;
export function CardsPaymentMethod() {
return (
<Card>
<CardHeader>
<CardTitle>Payment Method</CardTitle>
<CardDescription>Add a new payment method to your account.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<RadioGroup defaultValue="card" className="grid grid-cols-3 gap-4">
<div>
<RadioGroupItem value="card" id="card" className="peer sr-only" />
<Label
htmlFor="card"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="mb-3 h-6 w-6"
>
<rect width="20" height="14" x="2" y="5" rx="2" />
<path d="M2 10h20" />
</svg>
Card
</Label>
</div>
<div>
<RadioGroupItem value="paypal" id="paypal" className="peer sr-only" />
<Label
htmlFor="paypal"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<Icons.paypal className="mb-3 h-6 w-6" />
Paypal
</Label>
</div>
<div>
<RadioGroupItem value="apple" id="apple" className="peer sr-only" />
<Label
htmlFor="apple"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
<Icons.apple className="mb-3 h-6 w-6" />
Apple
</Label>
</div>
</RadioGroup>
<div className="grid gap-2">
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="First Last" />
</div>
<div className="grid gap-2">
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Plan</legend>
<p className="text-muted-foreground text-sm">
Select the plan that best fits your needs.
</p>
<RadioGroup defaultValue="starter" className="grid gap-3">
{plans.map((plan) => (
<Label
className="has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-primary/5 flex items-start gap-3 rounded-lg border p-3"
key={plan.id}
>
<RadioGroupItem
value={plan.id}
id={plan.name}
className="data-[state=checked]:border-primary"
/>
<div className="grid gap-1 font-normal">
<div className="font-medium">{plan.name}</div>
<div className="text-muted-foreground pr-2 text-xs leading-snug text-balance">
{plan.description}
</div>
</div>
</Label>
))}
</RadioGroup>
</fieldset>
<div className="flex flex-col gap-3">
<Label htmlFor="number">Card number</Label>
<Input id="number" placeholder="" />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<div className="flex flex-col gap-3">
<Label htmlFor="month">Expires</Label>
<Select>
<SelectTrigger id="month">
<SelectTrigger id="month" aria-label="Month" className="w-full">
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
@@ -102,10 +100,10 @@ export function DemoPaymentMethod() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<div className="flex flex-col gap-3">
<Label htmlFor="year">Year</Label>
<Select>
<SelectTrigger id="year">
<SelectTrigger id="year" aria-label="Year" className="w-full">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
@@ -117,7 +115,7 @@ export function DemoPaymentMethod() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<div className="flex flex-col gap-3">
<Label htmlFor="cvc">CVC</Label>
<Input id="cvc" placeholder="CVC" />
</div>
+265
View File
@@ -0,0 +1,265 @@
"use client";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { MoreHorizontalIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const data: Payment[] = [
{
id: "m5gr84i9",
amount: 316,
status: "success",
email: "ken99@example.com",
},
{
id: "3u1reuv4",
amount: 242,
status: "success",
email: "Abe45@example.com",
},
{
id: "derv1ws0",
amount: 837,
status: "processing",
email: "Monserrat44@example.com",
},
{
id: "bhqecj4p",
amount: 721,
status: "failed",
email: "carmella@example.com",
},
{
id: "k9f2m3n4",
amount: 450,
status: "pending",
email: "jason78@example.com",
},
{
id: "p5q6r7s8",
amount: 1280,
status: "success",
email: "sarah23@example.com",
},
];
export type Payment = {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
};
export const columns: ColumnDef<Payment>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <div className="capitalize">{row.getValue("status")}</div>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
// Format the amount as a dollar amount
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return <div className="text-right font-medium">{formatted}</div>;
},
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const payment = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(payment.id)}>
Copy payment ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View customer</DropdownMenuItem>
<DropdownMenuItem>View payment details</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
export function CardsPayments() {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<Card>
<CardHeader>
<CardTitle className="text-xl">Payments</CardTitle>
<CardDescription>Manage your payments.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
className="data-[name=actions]:w-10 data-[name=amount]:w-24 data-[name=select]:w-10 data-[name=status]:w-24 [&:has([role=checkbox])]:pl-3"
data-name={header.id}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="data-[name=actions]:w-10 data-[name=amount]:w-24 data-[name=select]:w-10 data-[name=status]:w-24 [&:has([role=checkbox])]:pl-3"
data-name={cell.column.id}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end gap-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,3 +1,7 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -18,19 +22,21 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export function DemoReportAnIssue() {
export function CardsReportIssue() {
const id = React.useId();
return (
<Card>
<CardHeader>
<CardTitle>Report an issue</CardTitle>
<CardDescription>What area are you having problems with?</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="area">Area</Label>
<CardContent className="flex flex-col gap-6">
<div className="grid gap-4 @2xl:grid-cols-2">
<div className="flex flex-col gap-3">
<Label htmlFor={`area-${id}`}>Area</Label>
<Select defaultValue="billing">
<SelectTrigger id="area">
<SelectTrigger id={`area-${id}`} aria-label="Area" className="w-full">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
@@ -42,10 +48,14 @@ export function DemoReportAnIssue() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="security-level">Security Level</Label>
<div className="flex flex-col gap-3">
<Label htmlFor={`security-level-${id}`}>Security Level</Label>
<Select defaultValue="2">
<SelectTrigger id="security-level">
<SelectTrigger
id={`security-level-${id}`}
className="w-full [&_span]:!block [&_span]:truncate"
aria-label="Security Level"
>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
@@ -57,21 +67,24 @@ export function DemoReportAnIssue() {
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="subject">Subject</Label>
<Input id="subject" placeholder="I need help with..." />
<div className="flex flex-col gap-3">
<Label htmlFor={`subject-${id}`}>Subject</Label>
<Input id={`subject-${id}`} placeholder="I need help with..." />
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<div className="flex flex-col gap-3">
<Label htmlFor={`description-${id}`}>Description</Label>
<Textarea
id="description"
id={`description-${id}`}
placeholder="Please include all information relevant to your issue."
className="min-h-28"
/>
</div>
</CardContent>
<CardFooter className="justify-between space-x-2">
<Button variant="ghost">Cancel</Button>
<Button>Submit</Button>
<CardFooter className="justify-end gap-2">
<Button variant="ghost" size="sm">
Cancel
</Button>
<Button size="sm">Submit</Button>
</CardFooter>
</Card>
);
@@ -1,108 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
export function DemoShareDocument() {
return (
<Card>
<CardHeader>
<CardTitle>Share this document</CardTitle>
<CardDescription>
Anyone with the link can view this document.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex space-x-2">
<Input value="http://example.com/link/to/document" readOnly />
<Button variant="secondary" className="shrink-0">
Copy Link
</Button>
</div>
<Separator className="my-4" />
<div className="space-y-4">
<div className="text-sm font-medium">People with access</div>
<div className="grid gap-6">
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src="/avatars/03.png" />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">Olivia Martin</p>
<p className="text-sm text-muted-foreground">m@example.com</p>
</div>
</div>
<Select defaultValue="edit">
<SelectTrigger className="ml-auto w-[110px]">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="edit">Can edit</SelectItem>
<SelectItem value="view">Can view</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src="/avatars/05.png" />
<AvatarFallback>IN</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">Isabella Nguyen</p>
<p className="text-sm text-muted-foreground">b@example.com</p>
</div>
</div>
<Select defaultValue="view">
<SelectTrigger className="ml-auto w-[110px]">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="edit">Can edit</SelectItem>
<SelectItem value="view">Can view</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src="/avatars/01.png" />
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">Sofia Davis</p>
<p className="text-sm text-muted-foreground">p@example.com</p>
</div>
</div>
<Select defaultValue="view">
<SelectTrigger className="ml-auto w-[110px]">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="edit">Can edit</SelectItem>
<SelectItem value="view">Can view</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
const people = [
{
name: "Olivia Martin",
email: "m@example.com",
avatar: "/avatars/03.png",
},
{
name: "Isabella Nguyen",
email: "b@example.com",
avatar: "/avatars/04.png",
},
{
name: "Sofia Davis",
email: "p@example.com",
avatar: "/avatars/05.png",
},
{
name: "Ethan Thompson",
email: "e@example.com",
avatar: "/avatars/01.png",
},
];
export function CardsShare() {
return (
<Card>
<CardHeader>
<CardTitle>Share this document</CardTitle>
<CardDescription>Anyone with the link can view this document.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Label htmlFor="link" className="sr-only">
Link
</Label>
<Input id="link" value="http://example.com/link/to/document" className="h-8" readOnly />
<Button size="sm" variant="outline" className="shadow-none">
Copy Link
</Button>
</div>
<Separator className="my-4" />
<div className="flex flex-col gap-4">
<div className="text-sm font-medium">People with access</div>
<div className="grid gap-6">
{people.map((person) => (
<div key={person.email} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Avatar>
<AvatarImage src={person.avatar} alt="Image" />
<AvatarFallback>{person.name.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm leading-none font-medium">{person.name}</p>
<p className="text-muted-foreground text-sm">{person.email}</p>
</div>
</div>
<Select defaultValue="edit">
<SelectTrigger className="ml-auto pr-2" aria-label="Edit">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="edit">Can edit</SelectItem>
<SelectItem value="view">Can view</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
+37 -25
View File
@@ -1,16 +1,18 @@
import { Bar, BarChart, Line, LineChart } from "recharts";
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Area, AreaChart, Line, LineChart } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
const data = [
{
revenue: 10400,
subscription: 240,
subscription: 40,
},
{
revenue: 14405,
subscription: 300,
subscription: 90,
},
{
revenue: 9400,
@@ -22,7 +24,7 @@ const data = [
},
{
revenue: 7000,
subscription: 189,
subscription: 89,
},
{
revenue: 9600,
@@ -30,11 +32,11 @@ const data = [
},
{
revenue: 11244,
subscription: 278,
subscription: 78,
},
{
revenue: 26475,
subscription: 189,
subscription: 89,
},
];
@@ -49,17 +51,17 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function DemoStats() {
export function CardsStats() {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
<div className="grid gap-4 @xl:grid-cols-2 @5xl:grid-cols-1 @7xl:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-normal">Total Revenue</CardTitle>
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-3xl">$15,231.89</CardTitle>
<CardDescription>+20.1% from last month</CardDescription>
</CardHeader>
<CardContent className="pb-0">
<div className="text-2xl font-bold">$15,231.89</div>
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
<ChartContainer config={chartConfig} className="h-[80px] w-full">
<ChartContainer config={chartConfig} className="h-[90px] w-full">
<LineChart
data={data}
margin={{
@@ -82,21 +84,31 @@ export function DemoStats() {
</ChartContainer>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-normal">Subscriptions</CardTitle>
<Card className="relative flex flex-col pb-0 @5xl:hidden @7xl:flex">
<CardHeader>
<CardDescription>Subscriptions</CardDescription>
<CardTitle className="text-3xl">+2,350</CardTitle>
<CardDescription>+180.1% from last month</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">+180.1% from last month</p>
<ChartContainer config={chartConfig} className="mt-2 h-[80px] w-full">
<BarChart data={data}>
<Bar
<CardContent className="relative mt-auto flex-1 p-0">
<ChartContainer config={chartConfig} className="relative size-full h-[90px]">
<AreaChart
data={data}
margin={{
left: 0,
right: 0,
}}
className="size-fit"
>
<Area
dataKey="subscription"
fill="var(--color-subscription)"
radius={4}
fillOpacity={0.05}
stroke="var(--color-subscription)"
strokeWidth={2}
type="monotone"
/>
</BarChart>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
+81 -112
View File
@@ -1,14 +1,10 @@
"use client";
import { ChevronDown } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
@@ -19,7 +15,47 @@ import {
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export function DemoTeamMembers() {
const teamMembers = [
{
name: "Sofia Davis",
email: "m@example.com",
avatar: "/avatars/01.png",
role: "Owner",
},
{
name: "Jackson Lee",
email: "p@example.com",
avatar: "/avatars/02.png",
role: "Developer",
},
{
name: "Isabella Nguyen",
email: "i@example.com",
avatar: "/avatars/03.png",
role: "Billing",
},
];
const roles = [
{
name: "Viewer",
description: "Can view and comment.",
},
{
name: "Developer",
description: "Can view, comment and edit.",
},
{
name: "Billing",
description: "Can view, comment and manage billing.",
},
{
name: "Owner",
description: "Admin-level access to all resources.",
},
];
export function CardsTeamMembers() {
return (
<Card>
<CardHeader>
@@ -27,112 +63,45 @@ export function DemoTeamMembers() {
<CardDescription>Invite your team members to collaborate.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src="/avatars/01.png" />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">Sofia Davis</p>
<p className="text-sm text-muted-foreground">m@example.com</p>
{teamMembers.map((member) => (
<div key={member.name} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Avatar className="border">
<AvatarImage src={member.avatar} alt="Image" />
<AvatarFallback>{member.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<p className="text-sm leading-none font-medium">{member.name}</p>
<p className="text-muted-foreground text-xs">{member.email}</p>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto shadow-none">
{member.role} <ChevronDown />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="end">
<Command>
<CommandInput placeholder="Select role..." />
<CommandList>
<CommandEmpty>No roles found.</CommandEmpty>
<CommandGroup>
{roles.map((role) => (
<CommandItem key={role.name}>
<div className="flex flex-col">
<p className="text-sm font-medium">{role.name}</p>
<p className="text-muted-foreground">{role.description}</p>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="ml-auto">
Owner <ChevronDown className="text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="end">
<Command>
<CommandInput placeholder="Select new role..." />
<CommandList>
<CommandEmpty>No roles found.</CommandEmpty>
<CommandGroup>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Viewer</p>
<p className="text-sm text-muted-foreground">
Can view and comment.
</p>
</CommandItem>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Developer</p>
<p className="text-sm text-muted-foreground">
Can view, comment and edit.
</p>
</CommandItem>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Billing</p>
<p className="text-sm text-muted-foreground">
Can view, comment and manage billing.
</p>
</CommandItem>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Owner</p>
<p className="text-sm text-muted-foreground">
Admin-level access to all resources.
</p>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src="/avatars/02.png" />
<AvatarFallback>JL</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium leading-none">Jackson Lee</p>
<p className="text-sm text-muted-foreground">p@example.com</p>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="ml-auto">
Member <ChevronDown className="text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="end">
<Command>
<CommandInput placeholder="Select new role..." />
<CommandList>
<CommandEmpty>No roles found.</CommandEmpty>
<CommandGroup className="p-1.5">
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Viewer</p>
<p className="text-sm text-muted-foreground">
Can view and comment.
</p>
</CommandItem>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Developer</p>
<p className="text-sm text-muted-foreground">
Can view, comment and edit.
</p>
</CommandItem>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Billing</p>
<p className="text-sm text-muted-foreground">
Can view, comment and manage billing.
</p>
</CommandItem>
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
<p>Owner</p>
<p className="text-sm text-muted-foreground">
Admin-level access to all resources.
</p>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
))}
</CardContent>
</Card>
);
-79
View File
@@ -1,79 +0,0 @@
import { cn } from "@/lib/utils";
import { DemoCookieSettings } from "./cards/cookie-settings";
import { DemoCreateAccount } from "./cards/create-account";
import { DemoDatePicker } from "./cards/date-picker";
import { DemoGithub } from "./cards/github-card";
import { DemoNotifications } from "./cards/notifications";
import { DemoPaymentMethod } from "./cards/payment-method";
import { DemoReportAnIssue } from "./cards/report-an-issue";
import { DemoShareDocument } from "./cards/share-document";
import { DemoTeamMembers } from "./cards/team-members";
import { DemoStats } from "./cards/stats";
import { DemoChat } from "./cards/chat";
import { DemoFontShowcase } from "./cards/font-showcase";
export function DemoContainer({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex items-center justify-center [&>div]:w-full", className)}
{...props}
/>
);
}
function DemoCards() {
return (
<div className="@container">
<div className="flex flex-col @4xl:flex-row items-center @4xl:items-start justify-center gap-4">
{/* First column */}
<div className="flex flex-col gap-4 max-w-lg">
<DemoContainer>
<DemoStats />
</DemoContainer>
<DemoContainer>
<DemoCreateAccount />
</DemoContainer>
<DemoContainer>
<DemoGithub />
</DemoContainer>
<DemoContainer>
<DemoCookieSettings />
</DemoContainer>
<DemoContainer>
<DemoTeamMembers />
</DemoContainer>
<DemoContainer>
<DemoFontShowcase />
</DemoContainer>
</div>
{/* Third column */}
<div className="flex flex-col gap-4 max-w-lg">
<DemoContainer>
<DemoReportAnIssue />
</DemoContainer>
<DemoContainer>
<DemoPaymentMethod />
</DemoContainer>
<DemoContainer>
<DemoShareDocument />
</DemoContainer>
<DemoContainer>
<DemoNotifications />
</DemoContainer>
<DemoContainer>
<DemoChat />
</DemoContainer>
<DemoContainer>
<DemoDatePicker />
</DemoContainer>
</div>
</div>
</div>
);
}
export default DemoCards;
@@ -0,0 +1,197 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Bookmark, Calendar, Clock, Heart, MessageCircle, Share2 } from "lucide-react";
import Image from "next/image";
export function BlogPost() {
return (
<Card className="bg-background flex min-h-screen flex-col overflow-hidden">
<div className="bg-card flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-3">
<div className="flex gap-2">
<div className="size-3 rounded-full bg-red-500"></div>
<div className="size-3 rounded-full bg-yellow-500"></div>
<div className="size-3 rounded-full bg-green-500"></div>
</div>
</div>
</div>
<div className="@container container mx-auto max-w-4xl px-4 py-8">
<article className="space-y-8">
{/* Header */}
<header className="space-y-6">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Technology</Badge>
<Badge variant="outline">Web Development</Badge>
<Badge variant="outline">React</Badge>
</div>
<h1 className="text-4xl leading-tight font-bold tracking-tight @md:text-5xl @lg:text-6xl">
The Future of Web Development: Embracing Modern Technologies
</h1>
<p className="text-muted-foreground text-xl leading-relaxed">
Discover how cutting-edge technologies are reshaping the landscape of web development,
from AI-powered tools to revolutionary frameworks that are changing how we build for
the web.
</p>
{/* Author and Meta */}
<div className="flex flex-col justify-between gap-4 pt-4 @sm:flex-row @sm:items-center">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src="/placeholder.svg?height=48&width=48" alt="Author" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">Jane Doe</p>
<p className="text-muted-foreground text-sm">Senior Developer</p>
</div>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>Dec 15, 2024</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>8 min read</span>
</div>
</div>
</div>
</header>
<Separator />
{/* Featured Image */}
<div className="relative aspect-video overflow-hidden rounded-lg">
<Image
src="/placeholder.svg?height=600&width=1200"
alt="Featured image"
fill
className="object-cover"
/>
</div>
{/* Content */}
<div className="prose prose-gray dark:prose-invert max-w-none">
<p className="text-lg leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
<h2 className="mt-8 mb-4 text-2xl font-bold">The Evolution of Modern Frameworks</h2>
<p>
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
officia deserunt mollit anim id est laborum.
</p>
<blockquote className="border-primary my-6 border-l-4 pl-6 text-lg italic">
&quot;The best way to predict the future is to create it. In web development,
we&apos;re not just following trends—we&apos;re setting them.&quot;
</blockquote>
<h3 className="mt-6 mb-3 text-xl font-semibold">Key Technologies Shaping the Future</h3>
<ul className="my-4 space-y-2">
<li>Artificial Intelligence and Machine Learning integration</li>
<li>Edge computing and serverless architectures</li>
<li>Progressive Web Applications (PWAs)</li>
<li>WebAssembly for high-performance applications</li>
<li>Advanced CSS features and container queries</li>
</ul>
<p>
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi
architecto beatae vitae dicta sunt explicabo.
</p>
<div className="my-8">
<Card>
<CardContent className="p-6">
<h4 className="mb-2 font-semibold">💡 Pro Tip</h4>
<p className="text-muted-foreground text-sm">
Always stay updated with the latest web standards and best practices. The web
development landscape evolves rapidly, and continuous learning is key to staying
relevant.
</p>
</CardContent>
</Card>
</div>
<h2 className="mt-8 mb-4 text-2xl font-bold">Looking Ahead</h2>
<p>
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro
quisquam est, qui dolorem ipsum quia dolor sit amet.
</p>
<p>
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium
voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint
occaecati cupiditate non provident.
</p>
</div>
<Separator />
{/* Engagement Actions */}
<div className="flex flex-col items-start justify-between gap-4 @sm:flex-row @sm:items-center">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="gap-2">
<Heart className="h-4 w-4" />
<span>42</span>
</Button>
<Button variant="ghost" size="sm" className="gap-2">
<MessageCircle className="h-4 w-4" />
<span>12</span>
</Button>
<Button variant="ghost" size="sm" className="gap-2">
<Share2 className="h-4 w-4" />
<span>Share</span>
</Button>
</div>
<Button variant="outline" size="sm" className="gap-2">
<Bookmark className="h-4 w-4" />
<span>Save for later</span>
</Button>
</div>
{/* Author Bio */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col gap-4 @sm:flex-row">
<Avatar className="h-16 w-16">
<AvatarImage src="/placeholder.svg?height=64&width=64" alt="Author" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="text-lg font-semibold">Jane Doe</h3>
<p className="text-muted-foreground mb-2">Senior Developer & Tech Writer</p>
<p className="text-sm">
Jane is a passionate developer with over 8 years of experience in web
development. She specializes in React, TypeScript, and modern web technologies.
When she&apos;s not coding, you can find her writing about the latest trends in
tech.
</p>
</div>
<Button variant="outline" size="sm" className="self-start">
Follow
</Button>
</div>
</CardContent>
</Card>
</article>
</div>
</Card>
);
}
@@ -0,0 +1,51 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
export function DemoFontShowcase() {
return (
<Card className="flex flex-1 flex-col">
<CardHeader>
<CardTitle>Font Showcase</CardTitle>
<CardDescription>View theme fonts in different styles</CardDescription>
</CardHeader>
<CardContent className="flex-coloverflow-hidden flex flex-1 px-0">
<ScrollArea className="flex size-full max-h-100 flex-1 flex-col px-6">
<div className="space-y-4">
<div>
<h3 className="text-muted-foreground mb-2 text-lg font-semibold">Sans-Serif</h3>
<div className="space-y-1 font-sans">
<div className="text-normal line-clamp-1 font-light">Light Weight Text</div>
<div className="text-normal line-clamp-1">Regular Weight Text</div>
<div className="text-normal line-clamp-1 font-medium">Medium Weight Text</div>
<div className="text-normal line-clamp-1 font-semibold">Semibold Weight Text</div>
<div className="text-normal line-clamp-1 font-bold">Bold Weight Text</div>
</div>
</div>
<div>
<h3 className="text-muted-foreground mb-2 text-lg font-semibold">Serif</h3>
<div className="space-y-1 font-serif">
<div className="text-normal line-clamp-1 font-light">Light Weight Text</div>
<div className="text-normal line-clamp-1">Regular Weight Text</div>
<div className="text-normal line-clamp-1 font-medium">Medium Weight Text</div>
<div className="text-normal line-clamp-1 font-semibold">Semibold Weight Text</div>
<div className="text-normal line-clamp-1 font-bold">Bold Weight Text</div>
</div>
</div>
<div>
<h3 className="text-muted-foreground mb-2 text-lg font-semibold">Monospace</h3>
<div className="space-y-1 font-mono">
<div className="text-normal line-clamp-1 font-light">Light Weight Text</div>
<div className="text-normal line-clamp-1">Regular Weight Text</div>
<div className="text-normal line-clamp-1 font-medium">Medium Weight Text</div>
<div className="text-normal line-clamp-1 font-semibold">Semibold Weight Text</div>
<div className="text-normal line-clamp-1 font-bold">Bold Weight Text</div>
</div>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
@@ -0,0 +1,15 @@
import { BlogPost } from "./blog-post";
import { DemoFontShowcase } from "./font-showcase";
export default function TypographyDemo() {
return (
<div className="@container relative grid grid-cols-9 gap-4">
<div className="sticky top-6 hidden size-full max-h-140 overflow-hidden lg:col-span-3 lg:block">
<DemoFontShowcase />
</div>
<div className="col-span-9 lg:col-span-6">
<BlogPost />
</div>
</div>
);
}
+22 -30
View File
@@ -2,15 +2,14 @@
import { Badge } from "@/components/ui/badge";
import { useEditorStore } from "@/store/editor-store";
import { motion } from "motion/react";
import { defaultPresets } from "@/utils/theme-presets";
import { DemoContainer } from "@/components/examples/demo-cards";
import { DemoGithub } from "@/components/examples/cards/github-card";
import { DemoStats } from "@/components/examples/cards/stats";
import { lazy, Suspense } from "react";
import { Loading } from "../loading";
import { useIsMobile } from "@/hooks/use-mobile";
import { motion } from "motion/react";
import { ThemePresetButtons } from "@/components/home/theme-preset-buttons";
import { lazy, Suspense } from "react";
import { GithubCard } from "../examples/cards/github-card";
import { CardsStats } from "../examples/cards/stats";
import { Loading } from "../loading";
const DemoMail = lazy(() => import("@/components/examples/mail"));
@@ -18,7 +17,6 @@ export function ThemePresetSelector() {
const { themeState, applyThemePreset } = useEditorStore();
const mode = themeState.currentMode;
const presetNames = Object.keys(defaultPresets);
const isMobile = useIsMobile();
return (
<section id="examples" className="w-full py-20 md:py-32">
@@ -28,22 +26,21 @@ export function ThemePresetSelector() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
className="mb-12 flex flex-col items-center justify-center space-y-4 text-center"
>
<div className="flex items-center justify-center gap-4 mb-4">
<div className="mb-4 flex items-center justify-center gap-4">
<Badge
className="rounded-full px-4 py-1.5 text-sm font-medium shadow-sm"
variant="secondary"
>
<span className="mr-1 text-primary">✦</span> Theme Presets
<span className="text-primary mr-1">✦</span> Theme Presets
</Badge>
</div>
<h2 className="text-3xl md:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/80">
<h2 className="from-foreground to-foreground/80 bg-gradient-to-r bg-clip-text text-3xl font-bold tracking-tight text-transparent md:text-4xl">
Elevate Your Design Instantly
</h2>
<p className="max-w-[800px] text-muted-foreground md:text-lg">
Apply theme presets with a single click. See how each option
enhances the look.
<p className="text-muted-foreground max-w-[800px] md:text-lg">
Apply theme presets with a single click. See how each option enhances the look.
</p>
</motion.div>
@@ -59,28 +56,23 @@ export function ThemePresetSelector() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="@container relative overflow-hidden border rounded-lg max-h-[60vh] md:max-h-[70vh] shadow-lg bg-gradient-to-b from-card/50 to-card/30 backdrop-blur-sm"
className="from-card/50 to-card/30 @container relative max-h-[60vh] overflow-hidden rounded-lg border bg-gradient-to-b shadow-lg backdrop-blur-sm md:max-h-[70vh]"
>
<div
className="absolute bottom-0 left-0 right-0 h-16 pointer-events-none z-10"
className="pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-16"
style={{
background:
"linear-gradient(to bottom, rgba(255,255,255,0), var(--background))",
background: "linear-gradient(to bottom, rgba(255,255,255,0), var(--background))",
}}
/>
<Suspense fallback={<Loading />}>
{!isMobile ? (
<div className="hidden lg:block">
<DemoMail />
) : (
<div className="p-4 flex flex-col gap-4">
<DemoContainer>
<DemoStats />
</DemoContainer>
<DemoContainer>
<DemoGithub />
</DemoContainer>
</div>
)}
</div>
<div className="flex flex-col gap-4 p-4 lg:hidden">
<CardsStats />
<GithubCard />
</div>
</Suspense>
</motion.div>
</div>
-2
View File
@@ -17,8 +17,6 @@ export function HorizontalScrollArea({ className, children, ...props }: Horizont
const leftMarker = leftMarkerRef.current;
const rightMarker = rightMarkerRef.current;
console.log(leftMarker, rightMarker);
if (!leftMarker || !rightMarker) return;
const observerOptions = {
-20
View File
@@ -1,20 +0,0 @@
import { EditorConfig } from "@/types/editor";
import { themeEditorConfig } from "./theme";
// Registry of all available editors
export const editorRegistry: Record<string, EditorConfig> = {
theme: themeEditorConfig,
// Add more editors here as they are created
};
// Helper function to get editor config by type
export const getEditorConfig = (type: string): EditorConfig => {
const config = editorRegistry[type];
if (!config) {
throw new Error(`Editor type "${type}" not found`);
}
return config;
};
// Export all editor types
export const editorTypes = Object.keys(editorRegistry);
-14
View File
@@ -1,14 +0,0 @@
import { EditorConfig } from "@/types/editor";
import ThemeControlPanel from "@/components/editor/theme-control-panel";
import ThemePreviewPanel from "@/components/editor/theme-preview-panel";
import { generateThemeCode } from "@/utils/theme-style-generator";
import { defaultThemeState } from "@/config/theme";
export const themeEditorConfig: EditorConfig = {
type: "theme",
name: "Theme",
description: "Customize your global theme colors",
defaultState: defaultThemeState,
controls: ThemeControlPanel,
preview: ThemePreviewPanel,
};
+66 -66
View File
@@ -26,44 +26,44 @@ export const DEFAULT_FONT_MONO =
// Default light theme styles
export const defaultLightThemeStyles = {
background: "hsl(0 0% 100%)",
foreground: "hsl(0 0% 14.5%)",
card: "hsl(0 0% 100%)",
"card-foreground": "hsl(0 0% 14.5%)",
popover: "hsl(0 0% 100%)",
"popover-foreground": "hsl(0 0% 14.5%)",
primary: "hsl(0 0% 20.5%)",
"primary-foreground": "hsl(0 0% 98.5%)",
secondary: "hsl(0 0% 97%)",
"secondary-foreground": "hsl(0 0% 20.5%)",
muted: "hsl(0 0% 97%)",
"muted-foreground": "hsl(0 0% 55.6%)",
accent: "hsl(0 0% 97%)",
"accent-foreground": "hsl(0 0% 20.5%)",
destructive: "hsl(0 80% 57.7%)",
"destructive-foreground": "hsl(0 0% 100%)",
border: "hsl(0 0% 92.2%)",
input: "hsl(0 0% 92.2%)",
ring: "hsl(0 0% 70.8%)",
"chart-1": "hsl(41 78% 64.6%)",
"chart-2": "hsl(185 68% 60%)",
"chart-3": "hsl(227 65% 39.8%)",
"chart-4": "hsl(84 63% 82.8%)",
"chart-5": "hsl(70 68% 76.9%)",
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.145 0 0)",
primary: "oklch(0.205 0 0)",
"primary-foreground": "oklch(0.985 0 0)",
secondary: "oklch(0.97 0 0)",
"secondary-foreground": "oklch(0.205 0 0)",
muted: "oklch(0.97 0 0)",
"muted-foreground": "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
"accent-foreground": "oklch(0.205 0 0)",
destructive: "oklch(0.577 0.245 27.325)",
"destructive-foreground": "oklch(1 0 0)",
border: "oklch(0.922 0 0)",
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
"chart-1": "oklch(0.81 0.10 252)",
"chart-2": "oklch(0.62 0.19 260)",
"chart-3": "oklch(0.55 0.22 263)",
"chart-4": "oklch(0.49 0.22 264)",
"chart-5": "oklch(0.42 0.18 266)",
radius: "0.625rem",
sidebar: "hsl(0 0% 98.5%)",
"sidebar-foreground": "hsl(0 0% 14.5%)",
"sidebar-primary": "hsl(0 0% 20.5%)",
"sidebar-primary-foreground": "hsl(0 0% 98.5%)",
"sidebar-accent": "hsl(0 0% 97%)",
"sidebar-accent-foreground": "hsl(0 0% 20.5%)",
"sidebar-border": "hsl(0 0% 92.2%)",
"sidebar-ring": "hsl(0 0% 70.8%)",
sidebar: "oklch(0.985 0 0)",
"sidebar-foreground": "oklch(0.145 0 0)",
"sidebar-primary": "oklch(0.205 0 0)",
"sidebar-primary-foreground": "oklch(0.985 0 0)",
"sidebar-accent": "oklch(0.97 0 0)",
"sidebar-accent-foreground": "oklch(0.205 0 0)",
"sidebar-border": "oklch(0.922 0 0)",
"sidebar-ring": "oklch(0.708 0 0)",
"font-sans": DEFAULT_FONT_SANS,
"font-serif": DEFAULT_FONT_SERIF,
"font-mono": DEFAULT_FONT_MONO,
"shadow-color": "hsl(0 0% 0%)",
"shadow-color": "oklch(0 0 0)",
"shadow-opacity": "0.1",
"shadow-blur": "3px",
"shadow-spread": "0px",
@@ -77,43 +77,43 @@ export const defaultLightThemeStyles = {
// Default dark theme styles
export const defaultDarkThemeStyles = {
...defaultLightThemeStyles,
background: "hsl(240 10% 3.9%)",
foreground: "hsl(0 0% 98%)",
card: "hsl(240 10% 3.9%)",
"card-foreground": "hsl(0 0% 98%)",
popover: "hsl(240 10% 3.9%)",
"popover-foreground": "hsl(0 0% 98%)",
primary: "hsl(0 0% 98%)",
"primary-foreground": "hsl(240 5.9% 10%)",
secondary: "hsl(240 3.7% 15.9%)",
"secondary-foreground": "hsl(0 0% 98%)",
muted: "hsl(240 3.7% 15.9%)",
"muted-foreground": "hsl(240 5% 64.9%)",
accent: "hsl(240 3.7% 15.9%)",
"accent-foreground": "hsl(0 0% 98%)",
destructive: "hsl(0 62.8% 30.6%)",
"destructive-foreground": "hsl(0 85.7% 97.3%)",
border: "hsl(240 3.7% 15.9%)",
input: "hsl(240 3.7% 15.9%)",
ring: "hsl(240 4.9% 83.9%)",
"chart-1": "hsl(220 70% 50%)",
"chart-2": "hsl(160 60% 45%)",
"chart-3": "hsl(30 80% 55%)",
"chart-4": "hsl(280 65% 60%)",
"chart-5": "hsl(340 75% 55%)",
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.269 0 0)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
"primary-foreground": "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
"muted-foreground": "oklch(0.708 0 0)",
accent: "oklch(0.371 0 0)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
"destructive-foreground": "oklch(0.985 0 0)",
border: "oklch(0.275 0 0)", // in place of oklch(1 0 0 / 10%)
input: "oklch(0.325 0 0)", // in place of oklch(1 0 0 / 15%)
ring: "oklch(0.556 0 0)",
"chart-1": "oklch(0.81 0.10 252)",
"chart-2": "oklch(0.62 0.19 260)",
"chart-3": "oklch(0.55 0.22 263)",
"chart-4": "oklch(0.49 0.22 264)",
"chart-5": "oklch(0.42 0.18 266)",
// Actual has radius but not in Expected, keeping it as is
radius: "0.625rem",
// Converting sidebar-related variables to match Actual format
sidebar: "hsl(240 5.9% 10%)",
"sidebar-foreground": "hsl(240 4.8% 95.9%)",
"sidebar-primary": "hsl(224.3 76.3% 48%)",
"sidebar-primary-foreground": "hsl(0 0% 100%)",
"sidebar-accent": "hsl(240 3.7% 15.9%)",
"sidebar-accent-foreground": "hsl(240 4.8% 95.9%)",
"sidebar-border": "hsl(240 3.7% 15.9%)",
"sidebar-ring": "hsl(240 4.9% 83.9%)",
sidebar: "oklch(0.205 0 0)",
"sidebar-foreground": "oklch(0.985 0 0)",
"sidebar-primary": "oklch(0.488 0.243 264.376)",
"sidebar-primary-foreground": "oklch(0.985 0 0)",
"sidebar-accent": "oklch(0.269 0 0)",
"sidebar-accent-foreground": "oklch(0.985 0 0)",
"sidebar-border": "oklch(0.275 0 0)", // in place of oklch(1 0 0 / 10%)
"sidebar-ring": "oklch(0.439 0 0)",
"shadow-color": "hsl(0 0% 0%)",
"shadow-color": "oklch(0 0 0)",
"letter-spacing": "0em",
spacing: "0.25rem",
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useAIChatStore } from "@/store/ai-chat-store";
export function useAIChat() {
const store = useAIChatStore();
return store;
}
+24 -54
View File
@@ -1,72 +1,42 @@
import { toast } from "@/components/ui/use-toast";
import { GenerateThemeOptions, useAIThemeGenerationStore } from "@/store/ai-theme-generation-store";
import { useAIThemeGenerationStore } from "@/store/ai-theme-generation-store";
// Hook for components that only need generation functionality
export function useAIThemeGeneration() {
const _generateTheme = useAIThemeGenerationStore((state) => state.generateTheme);
const generateTheme = useAIThemeGenerationStore((state) => state.generateTheme);
const loading = useAIThemeGenerationStore((state) => state.loading);
const cancelThemeGeneration = useAIThemeGenerationStore((state) => state.cancelThemeGeneration);
const generateTheme = async (options?: GenerateThemeOptions) => {
return await _generateTheme({
...options,
onSuccess: () => {
toast({
title: "Theme generated",
description: "Your AI-generated theme has been applied",
});
options?.onSuccess?.();
},
onError: (error) => {
if (error instanceof Error && error.name === "AbortError") {
toast({
title: "Theme generation cancelled",
description: "The theme generation was cancelled, no changes were made.",
});
return;
}
const handleGenerateTheme = async (prompt: string) => {
try {
const result = await generateTheme(prompt);
toast({
title: "Theme generated",
description: "Your AI-generated theme has been applied",
});
return result; // Return the result from the store
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
toast({
title: "Theme generation cancelled",
description: "The theme generation was cancelled, no changes were made.",
});
} else {
const description =
error instanceof Error ? error.message : "Failed to generate theme. Please try again.";
toast({
title: "Error",
description: "Failed to generate theme. Please try again.",
description,
variant: "destructive",
});
options?.onError?.(error);
},
});
}
}
};
return {
generateTheme,
generateTheme: handleGenerateTheme,
loading,
cancelThemeGeneration,
};
}
// Hook for components that only need prompt-related state and actions
export function useAIThemeGenerationPrompts() {
const prompt = useAIThemeGenerationStore((state) => state.prompt);
const jsonPrompt = useAIThemeGenerationStore((state) => state.jsonPrompt);
const setPrompt = useAIThemeGenerationStore((state) => state.setPrompt);
const setJsonPrompt = useAIThemeGenerationStore((state) => state.setJsonPrompt);
const resetPrompts = useAIThemeGenerationStore((state) => state.resetPrompts);
return {
prompt,
jsonPrompt,
setPrompt,
setJsonPrompt,
resetPrompts,
};
}
// Hook for components that need to know about the last generated theme
export function useAIThemeGenerationResult() {
const lastGeneratedTheme = useAIThemeGenerationStore((state) => state.lastGeneratedTheme);
const hasPrompted = useAIThemeGenerationStore((state) => state.hasPrompted);
return {
lastGeneratedTheme,
hasPrompted,
};
}
+32
View File
@@ -0,0 +1,32 @@
import { useQueryState } from "nuqs";
const TABS = ["colors", "typography", "other", "ai"] as const;
const DEFAULT_TAB = TABS[0];
export type ControlTab = (typeof TABS)[number];
export const useControlsTabFromUrl = () => {
const [tab, setTab] = useQueryState("tab", {
defaultValue: DEFAULT_TAB,
parse: (value: string) => {
// Synchronously validate the tab value, and if it's invalid, fallback to the default tab
if (!TABS.includes(value as ControlTab)) {
console.warn(`Invalid tab value: ${value}. Falling back to default.`);
return DEFAULT_TAB;
}
return value as ControlTab;
},
});
const handleSetTab = (tab: ControlTab) => {
// If the incoming tab is invalid, fallback to the default tab
if (!TABS.includes(tab)) {
console.warn(`Invalid tab value: ${tab}. Falling back to default.`);
setTab(DEFAULT_TAB);
return;
}
setTab(tab);
};
return { tab, handleSetTab };
};
+3 -53
View File
@@ -1,7 +1,6 @@
import { usePostHog } from "posthog-js/react";
import { createContext, ReactNode, useContext, useState } from "react";
import { AIGenerateDialog } from "@/components/editor/action-bar/components/ai-generate-dialog";
import { CodePanelDialog } from "@/components/editor/code-panel-dialog";
import CssImportDialog from "@/components/editor/css-import-dialog";
import { ShareDialog } from "@/components/editor/share-dialog";
@@ -21,7 +20,6 @@ interface DialogActionsContextType {
cssImportOpen: boolean;
codePanelOpen: boolean;
saveDialogOpen: boolean;
aiGenerateOpen: boolean;
shareDialogOpen: boolean;
shareUrl: string;
dialogKey: number;
@@ -32,14 +30,11 @@ interface DialogActionsContextType {
setCssImportOpen: (open: boolean) => void;
setCodePanelOpen: (open: boolean) => void;
setSaveDialogOpen: (open: boolean) => void;
setAiGenerateOpen: (open: boolean) => void;
setShareDialogOpen: (open: boolean) => void;
// Handler functions
handleCssImport: (css: string) => void;
handleSaveClick: (options?: { shareAfterSave?: boolean }) => void;
handleAiGenerateClick: () => void;
handleGenerateTheme: (promptText: string, jsonPromptText: string) => Promise<void>;
handleShareClick: (id?: string) => Promise<void>;
saveTheme: (themeName: string) => Promise<void>;
}
@@ -49,62 +44,28 @@ function useDialogActionsStore(): DialogActionsContextType {
const [codePanelOpen, setCodePanelOpen] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [shareAfterSave, setShareAfterSave] = useState(false);
const [aiGenerateOpen, setAiGenerateOpen] = useState(false);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [shareUrl, setShareUrl] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const {
themeState,
setThemeState,
applyThemePreset,
hasThemeChangedFromCheckpoint,
} = useEditorStore();
const { themeState, setThemeState, applyThemePreset, hasThemeChangedFromCheckpoint } =
useEditorStore();
const { getPreset } = useThemePresetStore();
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
const { createTheme, isCreatingTheme } = useThemeActions();
const { generateTheme, loading: aiGenerateLoading } = useAIThemeGeneration();
const { loading: aiGenerateLoading } = useAIThemeGeneration();
const posthog = usePostHog();
usePostLoginAction("SAVE_THEME", () => {
setSaveDialogOpen(true);
});
usePostLoginAction("AI_GENERATE_FROM_DIALOG", () => {
setAiGenerateOpen(true);
});
usePostLoginAction("SAVE_THEME_FOR_SHARE", () => {
setSaveDialogOpen(true);
setShareAfterSave(true);
});
const handleGenerateTheme = async (promptText: string, jsonPromptText: string) => {
if (!promptText.trim()) return;
await generateTheme({
prompt: promptText,
jsonPrompt: jsonPromptText,
onSuccess: () => {
setAiGenerateOpen(false);
setDialogKey((prev) => prev + 1);
},
});
posthog.capture("AI_GENERATE_THEME", {
prompt_text: promptText,
});
};
const handleAiGenerateClick = () => {
if (!session) {
openAuthDialog("signin", "AI_GENERATE_FROM_DIALOG");
return;
}
setAiGenerateOpen(true);
};
const handleCssImport = (css: string) => {
const { lightColors, darkColors } = parseCssInput(css);
const styles = {
@@ -198,7 +159,6 @@ function useDialogActionsStore(): DialogActionsContextType {
cssImportOpen,
codePanelOpen,
saveDialogOpen,
aiGenerateOpen,
shareDialogOpen,
shareUrl,
dialogKey,
@@ -209,14 +169,11 @@ function useDialogActionsStore(): DialogActionsContextType {
setCssImportOpen,
setCodePanelOpen,
setSaveDialogOpen,
setAiGenerateOpen,
setShareDialogOpen,
// Handler functions
handleCssImport,
handleSaveClick,
handleAiGenerateClick,
handleGenerateTheme,
handleShareClick,
saveTheme,
};
@@ -251,13 +208,6 @@ export function DialogActionsProvider({ children }: { children: ReactNode }) {
onSave={store.saveTheme}
isSaving={store.isCreatingTheme}
/>
<AIGenerateDialog
key={store.dialogKey}
open={store.aiGenerateOpen}
onOpenChange={store.setAiGenerateOpen}
loading={store.aiGenerateLoading}
onGenerate={store.handleGenerateTheme}
/>
<ShareDialog
open={store.shareDialogOpen}
onOpenChange={store.setShareDialogOpen}
+10 -24
View File
@@ -10,7 +10,7 @@ import { useEffect } from "react";
export type PostLoginActionType =
| "SAVE_THEME"
| "AI_GENERATE_FROM_DIALOG"
| "AI_GENERATE_FROM_PAGE"
| "AI_GENERATE_FROM_CHAT"
| "SAVE_THEME_FOR_SHARE";
@@ -23,7 +23,7 @@ export type StoredPostLoginAction = PostLoginActionPayload | null;
type PostLoginHandler<T = any> = (data?: T) => void | Promise<void>;
const handlers: Map<PostLoginActionType, PostLoginHandler[]> = new Map();
const handlers: Map<PostLoginActionType, PostLoginHandler> = new Map();
const readyActions: Set<PostLoginActionType> = new Set();
let pendingAction: StoredPostLoginAction = null;
@@ -34,12 +34,7 @@ export function usePostLoginAction<T = any>(
useEffect(() => {
if (!actionType) return;
if (!handlers.has(actionType)) {
handlers.set(actionType, []);
}
handlers.get(actionType)!.push(handler as PostLoginHandler); // Cast for simplicity, ensure T matches
// Signal this action type is ready to be executed if needed
handlers.set(actionType, handler as PostLoginHandler);
readyActions.add(actionType);
// If there's a pending action that matches this one, execute it
@@ -49,17 +44,10 @@ export function usePostLoginAction<T = any>(
}
return () => {
const actionHandlers = handlers.get(actionType);
if (actionHandlers) {
const index = actionHandlers.indexOf(handler as PostLoginHandler);
if (index > -1) {
actionHandlers.splice(index, 1);
}
// If no more handlers for this action, remove from ready set
if (actionHandlers.length === 0) {
readyActions.delete(actionType);
}
const handler = handlers.get(actionType);
if (handler) {
handlers.delete(actionType);
readyActions.delete(actionType);
}
};
}, [actionType, handler]);
@@ -71,12 +59,10 @@ async function executePostLoginActionInternal(actionPayload: StoredPostLoginActi
const actionType = actionPayload.type;
const actionData = actionPayload.data;
const actionHandlers = handlers.get(actionType);
const handler = handlers.get(actionType);
if (actionHandlers && actionHandlers.length > 0) {
for (const handler of actionHandlers) {
await handler(actionData);
}
if (handler) {
await handler(actionData);
}
}
+12 -59
View File
@@ -1,18 +1,13 @@
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { defaultThemeState } from "@/config/theme";
import { JSONContent } from "@tiptap/react";
import { useEditorStore } from "@/store/editor-store";
import { AIPromptData } from "@/types/ai";
import { Theme } from "@/types/theme";
interface GenerateThemeOptions {
onSuccess?: (themeStyles: Theme["styles"]) => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
}
import { buildPromptForAPI } from "@/utils/ai-prompt";
/**
* Generate a theme with AI using a text prompt
*/
export async function generateThemeWithAI(prompt: string, options?: GenerateThemeOptions) {
export async function generateThemeWithAI(prompt: string, options?: { signal?: AbortSignal }) {
if (!prompt.trim()) return null;
try {
@@ -26,42 +21,21 @@ export async function generateThemeWithAI(prompt: string, options?: GenerateThem
});
if (!response.ok) {
throw new Error("Failed to generate theme");
const errorBody = await response.text();
const errorMessage = errorBody || "Failed to generate theme. Please try again.";
throw new Error(errorMessage);
}
const themeStyles = await response.json();
applyGeneratedTheme(themeStyles);
const result = await response.json();
applyGeneratedTheme(result.theme);
options?.onSuccess?.(themeStyles);
return themeStyles;
return result;
} catch (error) {
console.error("AI theme generation error:", error);
if (error instanceof Error) {
options?.onError?.(error);
} else {
options?.onError?.(new Error("Unknown error occurred"));
}
throw error;
}
}
/**
* Generate a theme with AI using a structured prompt
* with references to existing themes/presets
*/
export async function generateThemeWithReferences(
textPrompt: string,
jsonPrompt: string,
options?: GenerateThemeOptions
) {
if (!textPrompt.trim()) return null;
const transformedPrompt = transformPrompt(textPrompt, jsonPrompt);
return generateThemeWithAI(transformedPrompt, options);
}
/**
* Apply a generated theme to the editor state
*/
@@ -94,27 +68,6 @@ export function applyGeneratedTheme(themeStyles: Theme["styles"]) {
}
}
/**
* Transform a prompt to include references to other themes
*/
function transformPrompt(prompt: string, jsonPrompt: string) {
const parsedJsonPrompt = JSON.parse(jsonPrompt) as JSONContent;
const mentions = parsedJsonPrompt.content?.[0]?.content?.filter(
(item) => item.type === "mention"
);
const getMentionContent = (id: string) => {
if (id === "editor:current-changes") {
return useEditorStore.getState().themeState.styles;
}
return useThemePresetStore.getState().getPreset(id)?.styles;
};
const mentionReferences = mentions?.map(
(mention) => `@${mention.attrs?.label} =
${JSON.stringify(getMentionContent(mention.attrs?.id))}`
);
return prompt + "\n\n" + (mentionReferences?.join("\n") || "");
export function buildPrompt(promptData: AIPromptData) {
return buildPromptForAPI(promptData);
}
+1
View File
@@ -0,0 +1 @@
export const AI_PROMPT_CHARACTER_LIMIT = 500;
+1
View File
@@ -1,6 +1,7 @@
import posthog from "posthog-js";
export function initPostHog() {
if (process.env.NODE_ENV === "development") return;
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (posthogKey) {
posthog.init(posthogKey, {
+1
View File
@@ -48,6 +48,7 @@
"@radix-ui/react-toggle-group": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.3",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-character-count": "^2.12.0",
"@tiptap/extension-mention": "^2.11.9",
"@tiptap/extension-placeholder": "^2.11.9",
"@tiptap/react": "^2.11.9",
+14
View File
@@ -122,6 +122,9 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/extension-character-count':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)
'@tiptap/extension-mention':
specifier: ^2.11.9
version: 2.11.9(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)(@tiptap/suggestion@2.11.9(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9))
@@ -2643,6 +2646,12 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-character-count@2.12.0':
resolution: {integrity: sha512-UCtcDyDj1nhKHc7AlOxR2PkFpRs7pWV8+/pfyWr471ECJHpJ2eDR3dyaH8uzkNiakHgqcwDca9lRu9lwHDfWcw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-code-block@2.11.9':
resolution: {integrity: sha512-brwvt/SdP65DpchPv5rkhjEjjIIgE1+9ySw8kCTiyXWUrmZA0kK/iwp5zPpHfsoWT8Sa9+fh2uraVZGOF9Dcqw==}
peerDependencies:
@@ -8443,6 +8452,11 @@ snapshots:
dependencies:
'@tiptap/core': 2.11.9(@tiptap/pm@2.11.9)
'@tiptap/extension-character-count@2.12.0(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)':
dependencies:
'@tiptap/core': 2.11.9(@tiptap/pm@2.11.9)
'@tiptap/pm': 2.11.9
'@tiptap/extension-code-block@2.11.9(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)':
dependencies:
'@tiptap/core': 2.11.9(@tiptap/pm@2.11.9)
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+94
View File
@@ -0,0 +1,94 @@
import { AssistantMessage, ChatMessage, UserMessage } from "@/types/ai";
import { ThemeStyles } from "@/types/theme";
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AIChatStore {
messages: ChatMessage[];
getDefaultMessage: () => ChatMessage;
addMessage: (message: ChatMessage) => void;
addUserMessage: (message: UserMessage) => void;
addAssistantMessage: (message: AssistantMessage) => void;
clearMessages: () => void;
resetMessagesUpToIndex: (index: number) => void;
}
export const useAIChatStore = create<AIChatStore>()(
persist(
(set) => ({
messages: [],
getDefaultMessage: () => {
const defaultMessage: ChatMessage = {
id: "default-message",
content: "How can I help you theme?",
role: "assistant",
timestamp: Date.now(),
};
return defaultMessage;
},
addMessage: (message: ChatMessage) => {
set((state) => ({ messages: [...state.messages, message] }));
},
addUserMessage: (message: UserMessage) => {
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
promptData: message.promptData,
role: "user",
timestamp: Date.now(),
};
set((state) => ({ messages: [...state.messages, userMessage] }));
},
addAssistantMessage: (message: AssistantMessage) => {
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
content: message.content,
themeStyles: message.themeStyles,
role: "assistant",
timestamp: Date.now(),
};
set((state) => ({ messages: [...state.messages, assistantMessage] }));
},
clearMessages: () => {
set({ messages: [] });
},
resetMessagesUpToIndex: (index: number) => {
set((state) => ({ messages: state.messages.slice(0, index) }));
},
}),
{
name: "ai-chat-storage",
}
)
);
export const getUserMessagesCount = (messages: ChatMessage[]) => {
return messages.filter((message) => message.role === "user").length;
};
export const getUserMessages = (messages: ChatMessage[]) => {
return messages.filter((message) => message.role === "user");
};
export const getAssistantMessages = (messages: ChatMessage[]) => {
return messages.filter((message) => message.role === "assistant");
};
export const getLastUserMessage = (messages: ChatMessage[]) => {
const userMessages = getUserMessages(messages);
return userMessages[userMessages.length - 1];
};
export const getLastGeneratedThemeStyles = (messages: ChatMessage[]): ThemeStyles | null => {
const assistantMessages = getAssistantMessages(messages);
// recursively find the last theme styles message starting from the last message
for (let i = assistantMessages.length - 1; i >= 0; i--) {
const message = assistantMessages[i];
if (message.themeStyles) {
return message.themeStyles;
}
}
return null;
};
+15 -55
View File
@@ -1,60 +1,28 @@
import { generateThemeWithReferences } from "@/lib/ai-theme-generator";
import { generateThemeWithAI } from "@/lib/ai-theme-generator";
import { ThemeStyles } from "@/types/theme";
import { create } from "zustand";
export interface GenerateThemeOptions {
prompt?: string;
jsonPrompt?: string;
onSuccess?: () => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
}
interface AIThemeGenerationStore {
loading: boolean;
prompt: string;
jsonPrompt: string;
hasPrompted: boolean;
lastGeneratedTheme: any | null;
abortController: AbortController | null;
// State setters
setLoading: (loading: boolean) => void;
setPrompt: (prompt: string) => void;
setJsonPrompt: (jsonPrompt: string) => void;
// Actions
generateTheme: (options?: GenerateThemeOptions) => Promise<any>;
// generateTheme now only takes prompt. Callbacks are removed.
generateTheme: (prompt: string) => Promise<{ text: string; theme: ThemeStyles }>;
cancelThemeGeneration: () => void;
// Utility methods
resetPrompts: () => void;
resetState: () => void;
updateLastGeneratedTheme: (theme: any) => void;
}
const initialState = {
loading: false,
prompt: "",
jsonPrompt: "",
hasPrompted: false,
lastGeneratedTheme: null,
abortController: null,
};
export const useAIThemeGenerationStore = create<AIThemeGenerationStore>()((set, get) => ({
...initialState,
// State setters
setLoading: (loading: boolean) => set({ loading }),
setPrompt: (prompt: string) => set({ prompt }),
setJsonPrompt: (jsonPrompt: string) => set({ jsonPrompt }),
// Utility methods
resetPrompts: () => set({ prompt: "", jsonPrompt: "" }),
resetState: () => set(initialState),
updateLastGeneratedTheme: (theme: any) => set({ lastGeneratedTheme: theme }),
// Cancel ongoing theme generation
cancelThemeGeneration: () => {
const { abortController } = get();
if (abortController) {
@@ -63,39 +31,31 @@ export const useAIThemeGenerationStore = create<AIThemeGenerationStore>()((set,
}
},
generateTheme: async (options?: GenerateThemeOptions) => {
generateTheme: async (prompt: string) => {
const state = get();
const finalPrompt = options?.prompt || state.prompt;
const finalJsonPrompt = options?.jsonPrompt || state.jsonPrompt;
if (!prompt.trim()) return; // Or throw new Error("Prompt cannot be empty");
if (!finalPrompt.trim()) return;
// Cancel any ongoing requests
if (state.abortController) {
state.abortController.abort();
}
// Create new AbortController for this request
const abortController = new AbortController();
set({ loading: true, abortController });
try {
const themeStyles = await generateThemeWithReferences(finalPrompt, finalJsonPrompt, {
onSuccess: options?.onSuccess,
onError: options?.onError,
const response = await generateThemeWithAI(prompt, {
signal: abortController.signal,
});
set({
hasPrompted: true,
lastGeneratedTheme: themeStyles,
});
return themeStyles;
return response;
} catch (error) {
console.error(error);
console.error("Error in store generateTheme:", error);
throw error;
} finally {
set({ loading: false, abortController: null });
if (get().abortController === abortController) {
set({ loading: false, abortController: null });
} else {
set({ loading: false });
}
}
},
}));
+8 -2
View File
@@ -184,7 +184,10 @@ export const useEditorStore = create<EditorStore>()(
const newFuture = [newFutureEntry, ...future];
set({
themeState: lastHistoryEntry.state,
themeState: {
...lastHistoryEntry.state,
currentMode: currentThemeState.currentMode,
},
themeCheckpoint: lastHistoryEntry.state,
history: newHistory,
future: newFuture,
@@ -209,7 +212,10 @@ export const useEditorStore = create<EditorStore>()(
}
set({
themeState: firstFutureEntry.state,
themeState: {
...firstFutureEntry.state,
currentMode: currentThemeState.currentMode,
},
themeCheckpoint: firstFutureEntry.state,
history: updatedHistory,
future: newFuture,
+33
View File
@@ -0,0 +1,33 @@
import { type ThemeStyles, type ThemeStyleProps } from "./theme";
export type MentionReference = {
id: string;
label: string;
themeData: {
light: Partial<ThemeStyleProps>;
dark: Partial<ThemeStyleProps>;
};
};
export type AIPromptData = {
content: string;
mentions: MentionReference[];
};
export type ChatMessage = {
id: string;
role: "user" | "assistant";
timestamp: number;
promptData?: AIPromptData;
content?: string;
themeStyles?: ThemeStyles;
};
export type UserMessage = {
promptData: AIPromptData;
};
export type AssistantMessage = {
content: string;
themeStyles?: ThemeStyles;
};
+123
View File
@@ -0,0 +1,123 @@
import { AIPromptData, MentionReference } from "@/types/ai";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { getLastGeneratedThemeStyles, useAIChatStore } from "@/store/ai-chat-store";
export const getTextContent = (promptData: AIPromptData | null) => {
if (!promptData) return "";
return promptData.content;
};
export const mentionsCount = (promptData: AIPromptData | null) => {
if (!promptData) return 0;
return promptData.mentions.length;
};
export const buildPromptForAPI = (promptData: AIPromptData) => {
const mentionReferences = promptData.mentions.map(
(mention) => `@${mention.label} =
${JSON.stringify(mention.themeData)}`
);
return `${promptData.content}\n\n${mentionReferences.join("\n")}`;
};
export const buildAIPromptRender = (promptData: AIPromptData): React.ReactNode => {
// Create a regex that matches all possible mention patterns from the actual mentions
const mentionPatterns = promptData.mentions.map(
(m) => `@${m.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
);
const mentionRegex = new RegExp(`(${mentionPatterns.join("|")})`, "g");
const parts = promptData.content.split(mentionRegex);
return parts.map((part, index) => {
const mention = promptData.mentions.find((m) => `@${m.label}` === part);
if (mention) {
return (
<span key={index} className="mention">
{part}
</span>
);
}
return part;
});
};
export function attachCurrentThemeMention(promptData: AIPromptData): AIPromptData {
const currentThemeData = useEditorStore.getState().themeState.styles;
const mentionReference: MentionReference = {
id: "editor:current-changes",
label: "Current Theme",
themeData: currentThemeData,
};
return {
content: promptData.content,
mentions: [...promptData.mentions, mentionReference],
};
}
export function attachLastGeneratedThemeMention(promptData: AIPromptData): AIPromptData {
const lastGeneratedThemeStyles = getLastGeneratedThemeStyles(useAIChatStore.getState().messages);
if (!lastGeneratedThemeStyles) return promptData;
const mentionReference: MentionReference = {
id: "ai:last-generated-theme",
label: "Last Generated Theme",
themeData: lastGeneratedThemeStyles,
};
return {
content: promptData.content,
mentions: [...promptData.mentions, mentionReference],
};
}
export function createCurrentThemePrompt({ prompt }: { prompt: string }): AIPromptData {
const currentThemeData = useEditorStore.getState().themeState.styles;
const mentionReference: MentionReference = {
id: "editor:current-changes",
label: "Current Theme",
themeData: currentThemeData,
};
return {
content: `Make the following changes to the @Current Theme:\n${prompt}`,
mentions: [mentionReference],
};
}
export function mentionsCurrentTheme(promptData: AIPromptData): boolean {
return promptData.mentions.some((mention) => mention.id === "editor:current-changes");
}
export function createPromptDataFromMentions(content: string, mentionIds: string[]): AIPromptData {
const mentions: MentionReference[] = mentionIds.map((id) => {
if (id === "editor:current-changes") {
return {
id,
label: "Current Theme",
themeData: useEditorStore.getState().themeState.styles,
};
}
const preset = useThemePresetStore.getState().getPreset(id);
if (!preset) {
throw new Error(`Theme preset not found: ${id}`);
}
return {
id,
label: preset.label || id,
themeData: preset.styles,
};
});
return {
content,
mentions,
};
}
+3 -40
View File
@@ -1,54 +1,17 @@
import { JSONContent } from "@tiptap/react";
export const PROMPTS = {
flatDesign: {
label: "Flat Design",
prompt:
"I want a flat design. Make the 'background', 'card', 'popover', and 'sidebar' tokens use the same color value, preferably the same as the 'background' color. Remove shadows completely, for example by setting 'shadow-opacity' to '0%' or 'shadow-blur' and 'shadow-spread' to '0px'. Default 'border' styles are okay.",
"I want a flat design. Make the surface color tokens use the same color value, preferably the same as the 'background' color. Remove shadows completely. Default 'border' styles are okay.",
},
minimalStyle: {
label: "Minimal Style",
prompt:
"Generate a minimalist theme palette. Use a very limited set of harmonious colors. All surfaces (background, card, popover, sidebar) should use subtle variations of the same base color, with enough contrast to distinguish them when they are next to each other. For brand colors (primary, secondary, muted, accent, ring) keep original color palette. Minimize borders and shadows. For shadows, you can set 'shadow-opacity' to 0% or near 0% or make them barely visible. For borders, use a subtle grayscale color. Typography should be clean, modern, and easy to read, with no decorative fonts. The overall look should be calm, simple, and highly functional.",
"Generate a minimalist theme palette. All surfaces color tokens should use subtle variations of the same base color, with enough contrast to distinguish them when they are next to each other. For brand colors, keep original color palette. Minimize borders and shadows. For borders, use a subtle grayscale color. Typography should be clean, modern, and easy to read.",
},
brutalist: {
label: "Make it Brutalist",
prompt:
"Make it brutalist style. Set 'radius' to '0px'. The 'border' color should strongly contrast with the 'background' color. For shadows, use a 'shadow-color' that also contrasts sharply with the 'background', set 'shadow-blur' to '0px', 'shadow-opacity' to '100%', and use 'shadow-offset-x', 'shadow-offset-y', and 'shadow-spread' to create a hard, offset shadow effect, but try not to exceed 4px. For brand colors (primary, secondary, muted, accent, ring) keep original color palette, and make colors slightly more vibrant, for that you can increase saturation, but keep hue-consistent.",
"Make it brutalist style. Set 'radius' to '0px'. The 'border' color should strongly contrast with the 'background' color. For shadows, use a 'shadow-color' that also contrasts sharply with the 'background', set 'shadow-blur' to '0px', 'shadow-opacity' to '100%', and use 'shadow-offset', and 'shadow-spread' to create a hard offset shadow effect, do not exceed 4px. Keep original color palette, and make colors slightly more vibrant.",
},
};
export function createCurrentThemePromptJson({ prompt }: { prompt: string }): JSONContent {
// Define the mention node structure
const mentionNode: JSONContent = {
type: "mention",
attrs: {
id: "editor:current-changes",
label: "Current Theme",
},
};
// Define the text parts
const textPart1: JSONContent = {
type: "text",
text: "Make the following changes based on ",
};
const textPart2: JSONContent = {
type: "text",
text: prompt,
};
// Combine into a paragraph
const paragraphNode: JSONContent = {
type: "paragraph",
content: [textPart1, mentionNode, textPart2],
};
// Combine into the top-level document
const docNode: JSONContent = {
type: "doc",
content: [paragraphNode],
};
return docNode;
}