diff --git a/app/(auth)/components/auth-dialog.tsx b/app/(auth)/components/auth-dialog.tsx index 32007730..35c21f77 100644 --- a/app/(auth)/components/auth-dialog.tsx +++ b/app/(auth)/components/auth-dialog.tsx @@ -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); diff --git a/app/ai/components/ai-announcement.tsx b/app/ai/components/ai-announcement.tsx new file mode 100644 index 00000000..cf8fd65b --- /dev/null +++ b/app/ai/components/ai-announcement.tsx @@ -0,0 +1,22 @@ +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; + +export function AIAnnouncement() { + return ( +
+ + + Beta + + + Try the new AI theme editor + + + + +
+ ); +} diff --git a/app/ai/components/ai-chat-form.tsx b/app/ai/components/ai-chat-form.tsx index 732c3672..b851cfd6 100644 --- a/app/ai/components/ai-chat-form.tsx +++ b/app/ai/components/ai-chat-form.tsx @@ -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: () => , }); -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(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 (
-
+
- - +
@@ -87,7 +56,6 @@ export function AIChatForm() {
- {/* TODO: Add image upload */} {aiGenerateLoading ? ( @@ -114,23 +82,3 @@ export function AIChatForm() {
); } - -function AIChatFormGeneratingFallback({ aiGenerateLoading }: { aiGenerateLoading: boolean }) { - return ( -
-
- -
-
- ); -} diff --git a/app/ai/components/ai-chat-hero.tsx b/app/ai/components/ai-chat-hero.tsx new file mode 100644 index 00000000..a66c2923 --- /dev/null +++ b/app/ai/components/ai-chat-hero.tsx @@ -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 ( +
+
+ + + {/* Chat form input and suggestions */} +
+
+ +
+ + {/* Quick suggestions */} + + + +
+
+
+ ); +} diff --git a/app/ai/components/ai-interface.tsx b/app/ai/components/ai-interface.tsx deleted file mode 100644 index 896448a6..00000000 --- a/app/ai/components/ai-interface.tsx +++ /dev/null @@ -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(null); - const [lastOpenLayout, setLastOpenLayout] = useState([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 ( -
- - {/* Chat section */} - -
- - - {/* TODO: Chat messages */} - - {/* Chat form input and suggestions */} -
-
- {hasPrompted && } - - - - {/* Background gradients */} -
-
-
- - {/* Quick suggestions */} - {!hasPrompted && ( - - - - )} -
- -

- tweakcn may make mistakes. Please use with discretion. -

-
-
- - {!isMobile && isPreviewPanelOpen && ( - - )} - - {/* Preview section */} - {!isMobile && ( - -
- -
-
- )} -
- - - - Preview Panel - -
- {isMobile && } -
-
-
-
- ); -} diff --git a/app/ai/components/community-theme-card.tsx b/app/ai/components/community-theme-card.tsx new file mode 100644 index 00000000..70991dbf --- /dev/null +++ b/app/ai/components/community-theme-card.tsx @@ -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 ( +
+
+
+ + +
+ + {/* TEMPORARY CARD IMPLEMENTATION: Based on `dashboard/components/theme-card.tsx` */} +
+ {/* Light mode swatches */} +
+
+ +
+ {swatchDefinitions.map((swatch) => ( +
+ ))} +
+ + {/* Dark mode swatches */} +
+
+ +
+ {swatchDefinitions.map((swatch) => ( +
+ ))} +
+
+
+ +
+
+ +
+
+

{themePreset.label}

+

+ {themePreset.createdAt ?? "Unknown creation date"} +

+
+ + +
+
+ ); +} + +export function CommunityThemeCardSkeleton() { + return ( +
+ + +
+ +
+ + +
+ + +
+
+ ); +} diff --git a/app/ai/components/community-themes.tsx b/app/ai/components/community-themes.tsx new file mode 100644 index 00000000..78477cbd --- /dev/null +++ b/app/ai/components/community-themes.tsx @@ -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 ( + <> +
+
+

From the Community

+ +
+

+ Explore the themes the community is creating with tweakcn. +

+
+ +
+ + + + + + } + > + + +
+ + ); +} + +interface CommunityThemeCardsProps { + themePresetsPromise: Promise>; +} + +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 + ); + + return ( + <> + {Object.values(presets).map((preset) => ( + + ))} + + ); +} diff --git a/app/ai/components/suggested-pill-actions.tsx b/app/ai/components/suggested-pill-actions.tsx index ce5af2f0..02135259 100644 --- a/app/ai/components/suggested-pill-actions.tsx +++ b/app/ai/components/suggested-pill-actions.tsx @@ -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 ( -
- {/* Fade out effect when scrolling */} -
- -
-

Suggestions

- -
- - - - -
- ); -} - -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 }]) => ( - handleSetPrompt(prompt)}> + handleSetPrompt(prompt)} + disabled={aiIsGenerating} + > {label} - + ))} ); } - -interface PillButtonProps extends ComponentProps {} - -function PillButton({ className, children, ...props }: PillButtonProps) { - const { loading: aiGenerateLoading } = useAIThemeGeneration(); - - return ( -
-
- -
- ); -} diff --git a/app/ai/components/toggle-preview-panel-button.tsx b/app/ai/components/toggle-preview-panel-button.tsx deleted file mode 100644 index 93da0624..00000000 --- a/app/ai/components/toggle-preview-panel-button.tsx +++ /dev/null @@ -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 {} - -export function TogglePreviewPanelButton({ - className, - onClick, - ...props -}: TogglePreviewPanelButtonProps) { - const { isPreviewPanelOpen, togglePreviewPanel } = usePreviewPanel(); - - const isMobile = useIsMobile(); - - if (isMobile && !isPreviewPanelOpen) { - return ( - - ); - } - - return ( - - ); -} diff --git a/app/ai/components/toolbar.tsx b/app/ai/components/toolbar.tsx deleted file mode 100644 index c67ab9a2..00000000 --- a/app/ai/components/toolbar.tsx +++ /dev/null @@ -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 ( - -
- - - -
-
- ); -} - -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) => { - const { clientX: x, clientY: y } = event; - toggleTheme({ x, y }); - }; - - const handleOpenCodePanel = () => { - setCodePanelOpen(true); - }; - - const handleOpenSaveDialog = () => { - handleSaveClick({ shareAfterSave: true }); - }; - - return ( -
- {/* Open in Editor Button */} - - - - - {/* Theme Toggle Button */} - - - - - {/* Undo Button */} - - - - - {/* Redo Button */} - - - - - {/* Reset Button */} - - - - - {/* Save Button */} - - - - - {/* Code Button */} - - - - - - - -
- ); -} diff --git a/app/ai/hooks/use-preview-panel.tsx b/app/ai/hooks/use-preview-panel.tsx deleted file mode 100644 index 20a7a9fd..00000000 --- a/app/ai/hooks/use-preview-panel.tsx +++ /dev/null @@ -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(undefined); - -export function PreviewPanelProvider({ children }: { children: React.ReactNode }) { - const [isPreviewPanelOpen, setIsPreviewPanelOpen] = useState(false); - - const togglePreviewPanel = () => { - console.log("Toggling preview panel"); - setIsPreviewPanelOpen((prev) => !prev); - }; - - return ( - - {children} - - ); -} - -export function usePreviewPanel() { - const context = useContext(PreviewPanelContext); - - if (context === undefined) { - throw new Error("usePreviewPanel must be used within a PreviewPanelProvider"); - } - - return context; -} diff --git a/app/ai/layout.tsx b/app/ai/layout.tsx index ceeac5b7..64049808 100644 --- a/app/ai/layout.tsx +++ b/app/ai/layout.tsx @@ -2,11 +2,9 @@ import { Header } from "@/components/header"; export default function AiLayout({ children }: { children: React.ReactNode }) { return ( -
-
-
-
-
{children}
+
+
+
{children}
); } diff --git a/app/ai/page.tsx b/app/ai/page.tsx index c1d78026..bc7eee2d 100644 --- a/app/ai/page.tsx +++ b/app/ai/page.tsx @@ -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 ( - - + <> -
- +
+ {/* AI Chat entry point section */} +
+ + +
- + ); } diff --git a/app/api/generate-theme/route.ts b/app/api/generate-theme/route.ts index 0c76736d..9ad0e4af 100644 --- a/app/api/generate-theme/route.ts +++ b/app/api/generate-theme/route.ts @@ -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 }); } diff --git a/app/editor/theme/[[...themeId]]/page.tsx b/app/editor/theme/[[...themeId]]/page.tsx index 801a71ea..6b9ea2ef 100644 --- a/app/editor/theme/[[...themeId]]/page.tsx +++ b/app/editor/theme/[[...themeId]]/page.tsx @@ -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 ( <>
-
+
}> - +
diff --git a/app/globals.css b/app/globals.css index 18cf97ef..9cbc8591 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} diff --git a/components/copy-button.tsx b/components/copy-button.tsx new file mode 100644 index 00000000..1039ffb1 --- /dev/null +++ b/components/copy-button.tsx @@ -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 { + textToCopy: string; + successMessage?: { + title?: string; + description?: string; + }; +} + +export function CopyButton({ textToCopy, successMessage, className, ...props }: CopyButtonProps) { + const { copyToClipboard, hasCopied } = useCopyToClipboard(); + + return ( + + + + ); +} diff --git a/components/editor/action-bar/action-bar.tsx b/components/editor/action-bar/action-bar.tsx index 46d8f8fe..aa4168fc 100644 --- a/components/editor/action-bar/action-bar.tsx +++ b/components/editor/action-bar/action-bar.tsx @@ -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 (
-
+ setCssImportOpen(true)} onCodeClick={() => setCodePanelOpen(true)} onSaveClick={() => handleSaveClick()} - onAiGenerateClick={handleAiGenerateClick} isSaving={isCreatingTheme} onShareClick={handleShareClick} /> -
+
); } diff --git a/components/editor/action-bar/components/action-bar-buttons.tsx b/components/editor/action-bar/components/action-bar-buttons.tsx index 63324a81..73b43a19 100644 --- a/components/editor/action-bar/components/action-bar-buttons.tsx +++ b/components/editor/action-bar/components/action-bar-buttons.tsx @@ -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 (
- + - + +
- - +
- - {isSavedPreset && } - onShareClick(themeState.preset)} /> - - + {isSavedPreset && ( + + )} + onShareClick(themeState.preset)} disabled={aiGenerationLoading} /> + +
); } diff --git a/components/editor/action-bar/components/ai-generate-dialog.tsx b/components/editor/action-bar/components/ai-generate-dialog.tsx deleted file mode 100644 index ecb48db3..00000000 --- a/components/editor/action-bar/components/ai-generate-dialog.tsx +++ /dev/null @@ -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 ( - - - - How can I help you theme? - - Use @ to mention a preset or - your current theme. - - - -
-
- }> - - -
- -
- Try{" "} - - @Modern Minimal but in red - {" "} - or{" "} - - Make the @Current Theme high contrast - -
-
- - -
- - -
-
-
-
- ); -} diff --git a/components/editor/action-bar/components/code-button.tsx b/components/editor/action-bar/components/code-button.tsx index 083e644f..6c4c17b9 100644 --- a/components/editor/action-bar/components/code-button.tsx +++ b/components/editor/action-bar/components/code-button.tsx @@ -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 {} -export function CodeButton({ onCodeClick }: CodeButtonProps) { +export function CodeButton({ className, ...props }: CodeButtonProps) { return ( - - - - - View theme code - + + + ); } diff --git a/components/editor/action-bar/components/edit-button.tsx b/components/editor/action-bar/components/edit-button.tsx index 9a79459a..c1c5e15d 100644 --- a/components/editor/action-bar/components/edit-button.tsx +++ b/components/editor/action-bar/components/edit-button.tsx @@ -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 { 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 ( - - - - - - - Edit theme - + + + + + ); } diff --git a/components/editor/action-bar/components/import-button.tsx b/components/editor/action-bar/components/import-button.tsx index 0d273937..cbf0a8a1 100644 --- a/components/editor/action-bar/components/import-button.tsx +++ b/components/editor/action-bar/components/import-button.tsx @@ -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 {} -export function ImportButton({ onImportClick }: ImportButtonProps) { +export function ImportButton({ className, ...props }: ImportButtonProps) { return ( - - - - - Import CSS variables - + + + ); } diff --git a/components/editor/action-bar/components/more-options.tsx b/components/editor/action-bar/components/more-options.tsx index e0e0a5df..b2db5a31 100644 --- a/components/editor/action-bar/components/more-options.tsx +++ b/components/editor/action-bar/components/more-options.tsx @@ -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 {} + +export function MoreOptions({ ...props }: MoreOptionsProps) { const [mcpDialogOpen, setMcpDialogOpen] = useState(false); const { themeState } = useEditorStore(); return ( <> - + @@ -30,9 +32,7 @@ export function MoreOptions() { MCP e.preventDefault()}> - + diff --git a/components/editor/action-bar/components/reset-button.tsx b/components/editor/action-bar/components/reset-button.tsx index a230d39e..d778e955 100644 --- a/components/editor/action-bar/components/reset-button.tsx +++ b/components/editor/action-bar/components/reset-button.tsx @@ -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 {} -export function ResetButton({ onReset, isDisabled }: ResetButtonProps) { +export function ResetButton({ className, ...props }: ResetButtonProps) { return ( - - - - - Reset to preset defaults - + + + ); } diff --git a/components/editor/action-bar/components/save-button.tsx b/components/editor/action-bar/components/save-button.tsx index dfc50036..0873e728 100644 --- a/components/editor/action-bar/components/save-button.tsx +++ b/components/editor/action-bar/components/save-button.tsx @@ -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 { isSaving: boolean; } -export function SaveButton({ onSaveClick, isSaving }: SaveButtonProps) { +export function SaveButton({ isSaving, disabled, className, ...props }: SaveButtonProps) { return ( - - - - - Save theme - + + + ); } diff --git a/components/editor/action-bar/components/share-button.tsx b/components/editor/action-bar/components/share-button.tsx index 9643a732..5e8bce38 100644 --- a/components/editor/action-bar/components/share-button.tsx +++ b/components/editor/action-bar/components/share-button.tsx @@ -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 { isSharing?: boolean; } -export function ShareButton({ onShareClick, isSharing }: ShareButtonProps) { +export function ShareButton({ + onClick, + isSharing, + disabled, + className, + ...props +}: ShareButtonProps) { return ( - - - - - Share theme - + + + ); } diff --git a/components/editor/action-bar/components/undo-redo-buttons.tsx b/components/editor/action-bar/components/undo-redo-buttons.tsx index d7ac7794..69d3e58f 100644 --- a/components/editor/action-bar/components/undo-redo-buttons.tsx +++ b/components/editor/action-bar/components/undo-redo-buttons.tsx @@ -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 {} + +export function UndoRedoButtons({ disabled, ...props }: UndoRedoButtonsProps) { const { undo, redo, canUndo, canRedo } = useEditorStore(); return ( - -
- - - - - -

Undo

-
-
- - - - - -

Redo

-
-
-
-
+
+ + + + + + + +
); } diff --git a/components/editor/ai/ai-chat-form.tsx b/components/editor/ai/ai-chat-form.tsx new file mode 100644 index 00000000..08175c82 --- /dev/null +++ b/components/editor/ai/ai-chat-form.tsx @@ -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: () => , +}); + +export function AIChatForm({ + handleThemeGeneration, +}: { + handleThemeGeneration: (promptData: AIPromptData | null) => void; +}) { + const [promptData, setPromptData] = useState(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 ( +
+
+ +
+
+ +
+
+ +
+ + + +
+ {/* TODO: Add image upload */} + {aiGenerateLoading ? ( + + + + ) : ( + + + + )} +
+
+
+
+ ); +} diff --git a/components/editor/ai/ai-interface.tsx b/components/editor/ai/ai-interface.tsx new file mode 100644 index 00000000..b231f060 --- /dev/null +++ b/components/editor/ai/ai-interface.tsx @@ -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 ( +
+
+ {hasMessages ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Chat form input and suggestions */} +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/components/editor/ai/ai-pill-action-button.tsx b/components/editor/ai/ai-pill-action-button.tsx new file mode 100644 index 00000000..94c5d04f --- /dev/null +++ b/components/editor/ai/ai-pill-action-button.tsx @@ -0,0 +1,31 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ComponentProps } from "react"; + +export interface AIPillActionButtonProps extends ComponentProps {} + +export function AIPillActionButton({ + className, + children, + disabled, + ...props +}: AIPillActionButtonProps) { + return ( +
+
+ +
+ ); +} diff --git a/components/editor/ai/chat-messages.tsx b/components/editor/ai/chat-messages.tsx new file mode 100644 index 00000000..c2f5f085 --- /dev/null +++ b/components/editor/ai/chat-messages.tsx @@ -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(messages); + + const messagesStartRef = useRef(null); + const messagesEndRef = useRef(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 ( + + {/* Top fade out effect when scrolling */} +
+
+
+
+ {messages.map((message, index) => ( + + ))} + + {/* Loading message when AI is generating */} + {isAIGenerating && ( +
+
+ +
+ +

+ Generating + . + . + . +

+
+ )} +
+
+
+ + ); +} + +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 ( +
+
+ {isAssistant && ( +
+ +
+ )} + +
+

+ {getDisplayContent()} +

+ + {isAssistant && message.themeStyles && ( +
+ + +
+ +
+
+
+
+ )} + +
+ {isUser && onRetry && messageIndex !== undefined && ( + + + + )} + + + + {isAssistant && message.themeStyles && ( + + + + )} +
+
+
+
+ ); +} diff --git a/components/editor/ai/chat-theme-preview.tsx b/components/editor/ai/chat-theme-preview.tsx new file mode 100644 index 00000000..ae0a448d --- /dev/null +++ b/components/editor/ai/chat-theme-preview.tsx @@ -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 ( +
+
setIsExpanded(!isExpanded)} + > +

+ Preview +

+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ {children} +
+
+
+
+ ); +} diff --git a/components/editor/ai/closeable-suggested-pill-actions.tsx b/components/editor/ai/closeable-suggested-pill-actions.tsx new file mode 100644 index 00000000..537c0333 --- /dev/null +++ b/components/editor/ai/closeable-suggested-pill-actions.tsx @@ -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 ( +
+ {/* Fade out effect when scrolling */} +
+ +
+

Suggestions

+ +
+ + + {Object.entries(PROMPTS).map(([key, { label, prompt }]) => ( + handleSetPrompt(prompt)} + disabled={aiIsGenerating} + > + {label} + + ))} + +
+ ); +} diff --git a/app/ai/components/loading-logo.tsx b/components/editor/ai/loading-logo.tsx similarity index 89% rename from app/ai/components/loading-logo.tsx rename to components/editor/ai/loading-logo.tsx index 0048c430..349909e7 100644 --- a/app/ai/components/loading-logo.tsx +++ b/components/editor/ai/loading-logo.tsx @@ -6,11 +6,11 @@ export function LoadingLogo() { <>
-
+
- +
diff --git a/components/editor/ai/no-messages-placeholder.tsx b/components/editor/ai/no-messages-placeholder.tsx new file mode 100644 index 00000000..59d6cdd1 --- /dev/null +++ b/components/editor/ai/no-messages-placeholder.tsx @@ -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 ( +
+

+ {heading} +

+ + + + + + + + + + + + + + + {CREATE_PROMPTS.map((prompt, index) => ( + + + handleThemeGeneration({ + content: prompt.prompt, + mentions: [], + }) + } + > + {prompt.displayContent} + + {index < CREATE_PROMPTS.length - 1 && } + + ))} + + + + {REMIX_PROMPTS.map((prompt, index) => ( + + + handleThemeGeneration( + createPromptDataFromPreset(prompt.prompt, prompt.basePreset) + ) + } + > + {prompt.displayContent} + + {index < REMIX_PROMPTS.length - 1 && } + + ))} + + + + {VARIANT_PROMPTS.map((prompt, index) => ( + + + handleThemeGeneration(createCurrentThemePrompt({ prompt: prompt.prompt })) + } + > + {prompt.displayContent} + + {index < VARIANT_PROMPTS.length - 1 && } + + ))} + + +
+ ); +} + +interface PromptButtonProps extends ComponentProps {} + +function PromptButton({ className, children, ...props }: PromptButtonProps) { + return ( + + ); +} diff --git a/components/editor/contrast-checker.tsx b/components/editor/contrast-checker.tsx index 1cb512b9..c3c8758a 100644 --- a/components/editor/contrast-checker.tsx +++ b/components/editor/contrast-checker.tsx @@ -189,13 +189,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => { return ( - - -
+ +
Contrast Checker @@ -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 -
+
- +
{groupedPairs.map((group) => (
@@ -256,35 +256,29 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {

{group.label}

-
+
{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 ( -
+

{pair.label} - {!isValid && ( - - )} + {!isValid && }

{
-
-
+
+
- - Background - - + Background + {pair.background}
@@ -332,16 +323,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
- - Foreground - - + Foreground + {pair.foreground}
@@ -350,16 +338,15 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
{pair.foreground && pair.background ? ( -
+

Aa

@@ -371,9 +358,7 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {

) : ( -

- Preview -

+

Preview

)}
diff --git a/components/editor/custom-textarea.tsx b/components/editor/custom-textarea.tsx index f6d0cb29..3a618100 100644 --- a/components/editor/custom-textarea.tsx +++ b/components/editor/custom-textarea.tsx @@ -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 = ({ 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 = ({ + 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 = ({ 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 = ({ 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 = ({ onContentChange, onGene return null; } - return ; + const characterCount = editor.storage.characterCount.characters(); + const isLimitExceeded = characterLimit && characterCount > characterLimit; + const shouldShowCount = characterLimit && characterCount >= characterLimit * 0.9; + + return ( +
+ + {shouldShowCount && ( +
+ + {characterCount} / {characterLimit} + +
+ )} +
+ ); }; export default CustomTextarea; diff --git a/components/editor/editor.tsx b/components/editor/editor.tsx index cba1bdd6..a8f4c683 100644 --- a/components/editor/editor.tsx +++ b/components/editor/editor.tsx @@ -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; } @@ -24,11 +24,9 @@ const isThemeStyles = (styles: unknown): styles is ThemeStyles => { ); }; -const Editor: React.FC = ({ config, themePromise }) => { +const Editor: React.FC = ({ 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 = ({ config, themePromise }) => { const styles = themeState.styles; return ( -
+
{/* Desktop Layout */} -
- - -
- + + +
+ = ({ config, themePromise }) => {
- +
- +
- {/* Mobile Layout */} -
+
@@ -102,7 +104,7 @@ const Editor: React.FC = ({ config, themePromise }) => {
- = ({ config, themePromise }) => {
- +
diff --git a/components/editor/mention-list.tsx b/components/editor/mention-list.tsx index 7f44084b..e40e6422 100644 --- a/components/editor/mention-list.tsx +++ b/components/editor/mention-list.tsx @@ -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( - (props, ref) => { - const [selectedIndex, setSelectedIndex] = useState(0); +export const MentionList = forwardRef((props, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const containerRef = useRef(null); + const selectedItemRef = useRef(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 ( -
- {props.items.length ? ( - props.items.map((item, index) => ( - - )) - ) : ( -
+ {props.items.length ? ( + props.items.map((item, index) => ( +
- )} -
- ); - } -); + {item.label} + + )) + ) : ( +
+ No result +
+ )} +
+ ); +}); MentionList.displayName = "MentionList"; diff --git a/components/editor/theme-control-panel.tsx b/components/editor/theme-control-panel.tsx index b544e606..00308a3c 100644 --- a/components/editor/theme-control-panel.tsx +++ b/components/editor/theme-control-panel.tsx @@ -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 = ({ <>
{!theme ? ( - + ) : ( - + )}
- -
+ handleSetTab(v as ControlTab)} + className="flex min-h-0 w-full flex-1 flex-col" + > + Colors Typography Other + + + + Generate + + -
+ - - + + - + + - -
+ + +

@@ -365,9 +382,11 @@ const ThemeControlPanel = ({ label="Letter Spacing" /> - + + - + + @@ -415,8 +434,12 @@ const ThemeControlPanel = ({ }} /> - - + + + + + +

diff --git a/components/editor/theme-edit-actions.tsx b/components/editor/theme-edit-actions.tsx index 7831f0cb..d8d4d136 100644 --- a/components/editor/theme-edit-actions.tsx +++ b/components/editor/theme-edit-actions.tsx @@ -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 = ({ theme }) => { +const ThemeEditActions: React.FC = ({ 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 = ({ 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 = ({ theme }) => { return ( <> -
-
-
+
+
+
- - Editing - + Editing
- - {theme.name} - + {theme.name}
- + @@ -97,6 +93,7 @@ const ThemeEditActions: React.FC = ({ theme }) => { size="icon" className="size-14 shrink-0 rounded-none" onClick={handleThemeEditCancel} + disabled={disabled} > @@ -105,7 +102,7 @@ const ThemeEditActions: React.FC = ({ theme }) => { - + @@ -115,6 +112,7 @@ const ThemeEditActions: React.FC = ({ theme }) => { size="icon" className="size-14 shrink-0 rounded-none" onClick={handleThemeEditSave} + disabled={disabled} > diff --git a/components/editor/theme-preset-select.tsx b/components/editor/theme-preset-select.tsx index f1e6ec47..93f5caa8 100644 --- a/components/editor/theme-preset-select.tsx +++ b/components/editor/theme-preset-select.tsx @@ -433,6 +433,7 @@ const ThemePresetSelect: React.FC = ({ filteredPresets={filteredPresets} currentPresetName={currentPresetName || "default"} className={className} + disabled={props.disabled} /> )}
diff --git a/components/editor/theme-preview-panel.tsx b/components/editor/theme-preview-panel.tsx index 973ba980..d6dbd91f 100644 --- a/components/editor/theme-preview-panel.tsx +++ b/components/editor/theme-preview-panel.tsx @@ -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 ( <>
isFullscreen && "bg-background fixed inset-0 z-50" )} > - -
+ + Cards
Dashboard - Pricing Mail - Tasks - Music
+ Pricing Color Palette + + + + + + + + + handleTabChange("typography")}> + Typography + + handleTabChange("tasks")} + className="hidden md:flex" + > + Tasks + + handleTabChange("music")} + className="hidden md:flex" + > + Music + + +
{isFullscreen && ( - - - - - Toggle Theme - - )} - - + - - {isFullscreen ? "Exit full screen" : "Full screen"} - + + )} + + +
-
+ - +
- + @@ -157,6 +196,12 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) => + + + + + + diff --git a/components/editor/theme-preview/color-preview.tsx b/components/editor/theme-preview/color-preview.tsx index 5c43a665..2a67083e 100644 --- a/components/editor/theme-preview/color-preview.tsx +++ b/components/editor/theme-preview/color-preview.tsx @@ -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) => ( -
-
-
-

{label}

-

{color}

+function ColorPreviewItem({ label, color }: { label: string; color: string }) { + return ( +
+
+
+

{label}

+

{color}

+
+ +
+ +
-
-); + ); +} const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => { if (!styles || !styles[currentMode]) { @@ -24,133 +29,139 @@ const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => { } return ( -
+
{/* Primary Colors */}
-

Primary Theme Colors

-
- {renderColorPreview("Background", styles[currentMode].background)} - {renderColorPreview("Foreground", styles[currentMode].foreground)} - {renderColorPreview("Primary", styles[currentMode].primary)} - {renderColorPreview( - "Primary Foreground", - styles[currentMode]["primary-foreground"] - )} +

Primary Theme Colors

+
+ + + +
+ + {/* Secondary & Accent Colors */}
-

+

Secondary & Accent Colors

-
- {renderColorPreview("Secondary", styles[currentMode].secondary)} - {renderColorPreview( - "Secondary Foreground", - styles[currentMode]["secondary-foreground"] - )} - {renderColorPreview("Accent", styles[currentMode].accent)} - {renderColorPreview( - "Accent Foreground", - styles[currentMode]["accent-foreground"] - )} +
+ + + +
+ + {/* UI Component Colors */}
-

UI Component Colors

-
- {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"] - )} +

UI Component Colors

+
+ + + + + +
+ + {/* Utility & Form Colors */}
-

Utility & Form Colors

-
- {renderColorPreview("Border", styles[currentMode].border)} - {renderColorPreview("Input", styles[currentMode].input)} - {renderColorPreview("Ring", styles[currentMode].ring)} - {renderColorPreview("Radius", styles[currentMode].radius)} +

Utility & Form Colors

+
+ + + +
+ + {/* Status & Feedback Colors */}
-

+

Status & Feedback Colors

-
- {renderColorPreview("Destructive", styles[currentMode].destructive)} - {renderColorPreview( - "Destructive Foreground", - styles[currentMode]["destructive-foreground"] - )} +
+ +
+ + {/* Chart & Data Visualization Colors */}
-

+

Chart & Visualization Colors

-
- {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"])} +
+ + + + +
+ + {/* Sidebar Colors */}
-

+

Sidebar & Navigation Colors

-
- {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"])} +
+ + + + + + + +
diff --git a/components/editor/theme-preview/examples-preview-container.tsx b/components/editor/theme-preview/examples-preview-container.tsx index 03100557..d36ba29b 100644 --- a/components/editor/theme-preview/examples-preview-container.tsx +++ b/components/editor/theme-preview/examples-preview-container.tsx @@ -24,7 +24,7 @@ const ExamplesPreviewContainer = ({ }) => { return (
-
+
}>{children}
diff --git a/components/editor/theme-preview/tabs-trigger-pill.tsx b/components/editor/theme-preview/tabs-trigger-pill.tsx index 28678af7..b3c23c69 100644 --- a/components/editor/theme-preview/tabs-trigger-pill.tsx +++ b/components/editor/theme-preview/tabs-trigger-pill.tsx @@ -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 ( {children} diff --git a/components/examples/cards/activity-goal.tsx b/components/examples/cards/activity-goal.tsx new file mode 100644 index 00000000..4d903df2 --- /dev/null +++ b/components/examples/cards/activity-goal.tsx @@ -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 ( + + + Move Goal + Set your daily activity goal. + + +
+ +
+
{goal}
+
Calories/day
+
+ +
+ + + + + +
+ + + +
+ ); +} diff --git a/components/examples/cards/calendar.tsx b/components/examples/cards/calendar.tsx new file mode 100644 index 00000000..ef9ff5f5 --- /dev/null +++ b/components/examples/cards/calendar.tsx @@ -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 ( + + + + + + ); +} diff --git a/components/examples/cards/chat.tsx b/components/examples/cards/chat.tsx index 32b9d3d2..b6cda9e8 100644 --- a/components/examples/cards/chat.tsx +++ b/components/examples/cards/chat.tsx @@ -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([]); @@ -88,14 +85,14 @@ export function DemoChat() { <> -
- +
+ - OM + S -
-

Sofia Davis

-

m@example.com

+
+

Sofia Davis

+

m@example.com

@@ -103,11 +100,11 @@ export function DemoChat() { @@ -116,15 +113,15 @@ export function DemoChat() { -
+
{messages.map((message, index) => (
{message.content} @@ -146,26 +143,31 @@ export function DemoChat() { ]); setInput(""); }} - className="flex w-full items-center space-x-2" + className="relative w-full" > setInput(event.target.value)} /> - - - + + New message Invite a user to this thread. This will create a new group message. @@ -175,63 +177,56 @@ export function DemoChat() { No users found. - + {users.map((user) => ( { 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)) ); }} > - + {user.name[0]}
-

{user.name}

-

{user.email}

+

{user.name}

+

{user.email}

{selectedUsers.includes(user) ? ( - + ) : null}
))}
- + {selectedUsers.length > 0 ? (
{selectedUsers.map((user) => ( - + {user.name[0]} ))}
) : ( -

- Select users to add to this thread. -

+

Select users to add to this thread.

)}
@@ -36,18 +45,16 @@ export function DemoCreateAccount() {
- - Or continue with - + Or continue with
-
- - +
+ +
-
- - +
+ +
diff --git a/components/examples/cards/date-picker-with-range.tsx b/components/examples/cards/date-picker-with-range.tsx index e3750274..b43e0614 100644 --- a/components/examples/cards/date-picker-with-range.tsx +++ b/components/examples/cards/date-picker-with-range.tsx @@ -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) { +export function DatePickerWithRange({ className }: React.HTMLAttributes) { const [date, setDate] = React.useState({ from: new Date(2022, 0, 20), to: addDays(new Date(2022, 0, 20), 20), }); return ( -
- - - - - - - - -
+ Pick a date + )} + + + + + + + + ); } diff --git a/components/examples/cards/date-picker.tsx b/components/examples/cards/date-picker.tsx deleted file mode 100644 index dde0ecbb..00000000 --- a/components/examples/cards/date-picker.tsx +++ /dev/null @@ -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 ( - - -
- - -
-
-
- ); -} diff --git a/components/examples/cards/exercise-minutes.tsx b/components/examples/cards/exercise-minutes.tsx new file mode 100644 index 00000000..917b6ad7 --- /dev/null +++ b/components/examples/cards/exercise-minutes.tsx @@ -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 ( + + + Exercise Minutes + + Your exercise minutes are ahead of where you normally are. + + + + + + + value.slice(0, 3)} + /> + + + } /> + + + + + ); +} diff --git a/components/examples/cards/font-showcase.tsx b/components/examples/cards/font-showcase.tsx deleted file mode 100644 index cb8b736c..00000000 --- a/components/examples/cards/font-showcase.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -export function DemoFontShowcase() { - return ( - - - Font Showcase - View theme fonts in different styles - - -
-

Sans-Serif

-
-
Light Weight Text
-
Regular Weight Text
-
Medium Weight Text
-
Semibold Weight Text
-
Bold Weight Text
-
-
- -
-

Serif

-
-
Light Weight Text
-
Regular Weight Text
-
Medium Weight Text
-
Semibold Weight Text
-
Bold Weight Text
-
-
- -
-

Monospace

-
-
Light Weight Text
-
Regular Weight Text
-
Medium Weight Text
-
Semibold Weight Text
-
Bold Weight Text
-
-
-
-
- ); -} diff --git a/components/examples/cards/forms.tsx b/components/examples/cards/forms.tsx new file mode 100644 index 00000000..52b00f5a --- /dev/null +++ b/components/examples/cards/forms.tsx @@ -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 ( + + + Upgrade your subscription + + You are currently on the free plan. Upgrade to the pro plan to get access to all features. + + + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+
+ Plan +

+ Select the plan that best fits your needs. +

+ + {plans.map((plan) => ( + + ))} + +
+
+ +