mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-10-29 19:23:00 +08:00
ai chat (#107)
* Enhanced Editor (#100) * refactor: editor page to improve responsiveness * feat: add editor tab persistance to url * chore: clean up classNames * feat: enhance theme color-preview to make it responsive * feat: add ai chat tab working with local state * successful responses return a theme color-preview * error responses return a generic message * fix: invalid tabs fallback to default tab "colors" * fix: remove messages scroll animation on tabs change and remove duplicate preview * fix: minor fixes in classNames * feat: Chat starts with a default message * refactor: Enhance editor controls and toolbar buttons to disable when generating theme - Updated button components to be extendible. - Refactored buttons to utilize TooltipWrapper for consistent tooltip behavior. * fix: sync controls tabs by 'value' instead of 'defaultValue' * fix: load messages from local storage, otherwise set a default initial message * fix: editor mention list positions correctly and add a tab handler for selecting mention * fix: build errors due to unused imports * chore: improve defaults and styles for ai chat interface and editor * refactor: move AIChat state management to a zustand store * feat: preserve control tab between edit mode and traditional editor mode * feat: reset ai chat on editor reset button click * feat: load chat messages dynamically to avoid layout shifts due to local storage * fix: Use const id for default ai message to avoid re-render * fix: Use searchParams for callback url as well after auth * refactor: Remove unused getEditorConfig code * feat: Only have 1 handler per postlogin action type This was causing issues in AI Chat form. AI_GENERATE_FROM_CHAT action was being scheduled twice because of re-render (caused by lazy-loaded component) * fix: Add user message for post login action * refactor: Only use JSONContent in application state (derive textContent from it) * feat: Auto-attach current theme to prompt after 1st user message * feat: clean up AI interface and add community themes section * feat: AI entrypoint redirect to main Editor and trigger theme generation * chore: improve styles on ai page * refactor: clean up AI components components from AI page and Editor page * refactor: share repeated theme generation logic * fix: chat messages inconsistent padding and pill buttons disabled state * fix: build error * feat: remove AI Generation Dialog and related logic * feat: show agent response about the theme * refactor: simplify theme generation logic and improve error handling * refactor: transition from JSONContent to AIPromptData for theme generation * feat: add retry functionality for user messages in AI chat interface * refactor: remove unused loading fallback component and integrate loading indicator in chat messages * change position of retry button * feat: Improve system prompt * refactor: replace createCurrentThemePrompt with attachCurrentThemeMention for improved prompt handling * feat: add a placeholder for new chat and fix chat area styles * chore: hide Community themes from AI page * fix: build errors * fix: ai theme generation only happens when there is content to be sent as the prompt * feat: show AI tab in production * fix: Early return after showing error message in ai chat * feat: Attach last generated message instead of current theme for chat * Use @ mentions in example prompts * Enhance mention rendering in AI prompts and update mention styles in CSS * skip posthog on dev * Enhanced Editor (#100) * refactor: editor page to improve responsiveness * feat: add editor tab persistance to url * chore: clean up classNames * feat: enhance theme color-preview to make it responsive * feat: add ai chat tab working with local state * successful responses return a theme color-preview * error responses return a generic message * fix: invalid tabs fallback to default tab "colors" * fix: remove messages scroll animation on tabs change and remove duplicate preview * fix: minor fixes in classNames * feat: Chat starts with a default message * refactor: Enhance editor controls and toolbar buttons to disable when generating theme - Updated button components to be extendible. - Refactored buttons to utilize TooltipWrapper for consistent tooltip behavior. * fix: sync controls tabs by 'value' instead of 'defaultValue' * fix: load messages from local storage, otherwise set a default initial message * fix: editor mention list positions correctly and add a tab handler for selecting mention * fix: build errors due to unused imports * chore: improve defaults and styles for ai chat interface and editor * refactor: move AIChat state management to a zustand store * feat: preserve control tab between edit mode and traditional editor mode * feat: reset ai chat on editor reset button click * feat: load chat messages dynamically to avoid layout shifts due to local storage * fix: Use const id for default ai message to avoid re-render * fix: Use searchParams for callback url as well after auth * refactor: Remove unused getEditorConfig code * feat: Only have 1 handler per postlogin action type This was causing issues in AI Chat form. AI_GENERATE_FROM_CHAT action was being scheduled twice because of re-render (caused by lazy-loaded component) * fix: Add user message for post login action * refactor: Only use JSONContent in application state (derive textContent from it) * feat: Auto-attach current theme to prompt after 1st user message * feat: clean up AI interface and add community themes section * feat: AI entrypoint redirect to main Editor and trigger theme generation * chore: improve styles on ai page * refactor: clean up AI components components from AI page and Editor page * refactor: share repeated theme generation logic * fix: chat messages inconsistent padding and pill buttons disabled state * fix: build error * feat: remove AI Generation Dialog and related logic * feat: show agent response about the theme * refactor: simplify theme generation logic and improve error handling * refactor: transition from JSONContent to AIPromptData for theme generation * feat: add retry functionality for user messages in AI chat interface * refactor: remove unused loading fallback component and integrate loading indicator in chat messages * change position of retry button * feat: Improve system prompt * refactor: replace createCurrentThemePrompt with attachCurrentThemeMention for improved prompt handling * feat: add a placeholder for new chat and fix chat area styles * chore: hide Community themes from AI page * fix: build errors * fix: ai theme generation only happens when there is content to be sent as the prompt * feat: show AI tab in production * fix: Early return after showing error message in ai chat * feat: Attach last generated message instead of current theme for chat * Use @ mentions in example prompts * Enhance mention rendering in AI prompts and update mention styles in CSS * fix: missing fade effect on chat messages scroll area and overflow in placeholder buttons * feat: add user name from session to the chat placeholder component * feat: Improve system prompt context * chore: remove unnecessary context from example prompts * feat: Enhance mention list with auto-scroll functionality and update prompt structures in no-messages placeholder * feat: Add character limit for AI prompt input and integrate character count extension * fix: Simplify prompt data handling in AI chat component and adjust textarea behavior during AI generation * fix: Update theme state handling to include current mode in editor store * chore: improve styles for consistency * feat: add fun prompts to chat placeholder * feat: add typography demo to preview panel * feat: add responsive cards demo based on shadcn showcase * fix: typography demo scroll area * fix: build errors due to components refactor * fix: wrap typography demo in ExamplesPreviewContainer * feat: change default theme to match shadcn/ui default * chore: move some theme previews into a dropdown to keep ui clean --------- Co-authored-by: Luis Llanes <137589205+llanesluis@users.noreply.github.com> Co-authored-by: Luis Llanes <luisllaboj@gmail.com>
This commit is contained in:
co-authored by
Luis Llanes
Luis Llanes
parent
686b38eee1
commit
2548f49de1
@@ -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);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function AIAnnouncement() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Link
|
||||
href={"/editor/theme?tab=ai"}
|
||||
className="group bg-muted flex items-center justify-between gap-2 rounded-full px-2 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<span className="bg-primary text-primary-foreground rounded-full px-1.5 py-0.5 text-xs font-medium capitalize">
|
||||
Beta
|
||||
</span>
|
||||
<span className="text-muted-foreground group-hover:text-foreground text-sm font-medium transition-colors">
|
||||
Try the new AI theme editor
|
||||
</span>
|
||||
|
||||
<ArrowRight className="text-muted-foreground group-hover:text-foreground size-4 -rotate-45 transition-all group-hover:rotate-0" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,81 +3,50 @@
|
||||
import ThemePresetSelect from "@/components/editor/theme-preset-select";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIThemeGeneration, useAIThemeGenerationPrompts } from "@/hooks/use-ai-theme-generation";
|
||||
import { usePostLoginAction } from "@/hooks/use-post-login-action";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { JSONContent } from "@tiptap/react";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { ArrowUp, Loader, StopCircle } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePreviewPanel } from "../hooks/use-preview-panel";
|
||||
import { LoadingLogo } from "./loading-logo";
|
||||
import { useState } from "react";
|
||||
|
||||
const CustomTextarea = dynamic(() => import("@/components/editor/custom-textarea"), {
|
||||
ssr: false,
|
||||
loading: () => <Loading className="min-h-[60px] w-full rounded-lg" />,
|
||||
});
|
||||
|
||||
export function AIChatForm() {
|
||||
const { prompt, jsonPrompt, setPrompt, setJsonPrompt } = useAIThemeGenerationPrompts();
|
||||
const {
|
||||
generateTheme,
|
||||
loading: aiGenerateLoading,
|
||||
cancelThemeGeneration,
|
||||
} = useAIThemeGeneration();
|
||||
export function AIChatForm({
|
||||
handleThemeGeneration,
|
||||
}: {
|
||||
handleThemeGeneration: (promptData: AIPromptData | null) => void;
|
||||
}) {
|
||||
const [promptData, setPromptData] = useState<AIPromptData | null>(null);
|
||||
const { loading: aiGenerateLoading, cancelThemeGeneration } = useAIThemeGeneration();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const { openAuthDialog } = useAuthStore();
|
||||
|
||||
const { setIsPreviewPanelOpen } = usePreviewPanel();
|
||||
const handleSuccessfulThemeGeneration = () => {
|
||||
setIsPreviewPanelOpen(true);
|
||||
// TODO: reset prompts
|
||||
};
|
||||
|
||||
usePostLoginAction("AI_GENERATE_FROM_CHAT", ({ prompt, jsonPrompt }) => {
|
||||
if (!prompt || !jsonPrompt) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate theme. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
generateTheme({
|
||||
prompt,
|
||||
jsonPrompt,
|
||||
onSuccess: handleSuccessfulThemeGeneration,
|
||||
});
|
||||
});
|
||||
|
||||
const handleContentChange = (textContent: string, jsonContent: JSONContent) => {
|
||||
setJsonPrompt(JSON.stringify(jsonContent));
|
||||
setPrompt(textContent);
|
||||
const handleContentChange = (content: AIPromptData) => {
|
||||
setPromptData(content);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!session) {
|
||||
openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", { prompt, jsonPrompt });
|
||||
return;
|
||||
}
|
||||
|
||||
await generateTheme({ prompt, jsonPrompt, onSuccess: handleSuccessfulThemeGeneration });
|
||||
if (!promptData?.content) return;
|
||||
handleThemeGeneration(promptData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="@container/form relative transition-all">
|
||||
<div className="bg-background relative z-10 flex size-full min-h-[100px] flex-1 flex-col overflow-hidden rounded-lg border shadow-xs">
|
||||
<label className="sr-only">Chat Input</label>
|
||||
<div className={cn("min-h-[80px] p-2 pb-0", aiGenerateLoading && "pointer-events-none")}>
|
||||
<div className={cn("min-h-[60px] p-2 pb-0", aiGenerateLoading && "pointer-events-none")}>
|
||||
<div
|
||||
className="bg-muted/40 relative isolate rounded-lg"
|
||||
aria-disabled={aiGenerateLoading}
|
||||
>
|
||||
<AIChatFormGeneratingFallback aiGenerateLoading={aiGenerateLoading} />
|
||||
<CustomTextarea onContentChange={handleContentChange} onGenerate={handleGenerate} />
|
||||
<CustomTextarea
|
||||
onContentChange={handleContentChange}
|
||||
onGenerate={handleGenerate}
|
||||
characterLimit={AI_PROMPT_CHARACTER_LIMIT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +56,6 @@ export function AIChatForm() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* TODO: Add image upload */}
|
||||
{aiGenerateLoading ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -103,7 +71,7 @@ export function AIChatForm() {
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt || aiGenerateLoading}
|
||||
disabled={!promptData?.content || aiGenerateLoading}
|
||||
>
|
||||
{aiGenerateLoading ? <Loader className="animate-spin" /> : <ArrowUp />}
|
||||
</Button>
|
||||
@@ -114,23 +82,3 @@ export function AIChatForm() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AIChatFormGeneratingFallback({ aiGenerateLoading }: { aiGenerateLoading: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/50 pointer-events-none absolute inset-0 z-1 flex size-full items-center justify-center opacity-0 backdrop-blur-sm transition-all duration-150 ease-out",
|
||||
aiGenerateLoading && "pointer-events-auto opacity-100"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground size-8 scale-0 opacity-0 transition-all duration-150 ease-out md:size-10",
|
||||
aiGenerateLoading && "scale-100 opacity-100"
|
||||
)}
|
||||
>
|
||||
<LoadingLogo />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { usePostLoginAction } from "@/hooks/use-post-login-action";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useAIChatStore } from "@/store/ai-chat-store";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AIChatForm } from "./ai-chat-form";
|
||||
import { ChatHeading } from "./chat-heading";
|
||||
import { SuggestedPillActions } from "./suggested-pill-actions";
|
||||
import { buildPrompt } from "@/lib/ai-theme-generator";
|
||||
|
||||
export function AIChatHero() {
|
||||
const { addUserMessage, addAssistantMessage, clearMessages } = useAIChatStore();
|
||||
const router = useRouter();
|
||||
const { generateTheme } = useAIThemeGeneration();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { openAuthDialog } = useAuthStore();
|
||||
|
||||
const handleRedirectAndThemeGeneration = async (promptData: AIPromptData | null) => {
|
||||
if (!session) {
|
||||
if (promptData) {
|
||||
openAuthDialog("signup", "AI_GENERATE_FROM_PAGE", { promptData });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptData) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate theme. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the messages when the user starts a chat from the '/ai' page
|
||||
clearMessages();
|
||||
|
||||
addUserMessage({
|
||||
promptData,
|
||||
});
|
||||
|
||||
router.push("/editor/theme?tab=ai");
|
||||
|
||||
const result = await generateTheme(buildPrompt(promptData));
|
||||
|
||||
if (!result) {
|
||||
addAssistantMessage({
|
||||
content: "Failed to generate theme.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
addAssistantMessage({
|
||||
content:
|
||||
result?.text ??
|
||||
(result?.theme ? "Here's the theme I generated for you." : "Failed to generate theme."),
|
||||
themeStyles: result?.theme,
|
||||
});
|
||||
};
|
||||
|
||||
usePostLoginAction("AI_GENERATE_FROM_PAGE", ({ promptData }) => {
|
||||
if (!promptData) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate theme. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleRedirectAndThemeGeneration(promptData);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex w-full flex-1 overflow-hidden">
|
||||
<div className="@container relative isolate z-1 mx-auto flex max-w-[49rem] flex-1 flex-col justify-center px-4">
|
||||
<ChatHeading />
|
||||
|
||||
{/* Chat form input and suggestions */}
|
||||
<div className="relative mx-auto flex w-full flex-col">
|
||||
<div className="relative isolate z-10 w-full">
|
||||
<AIChatForm handleThemeGeneration={handleRedirectAndThemeGeneration} />
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
<HorizontalScrollArea className="mx-auto pt-4 pb-2">
|
||||
<SuggestedPillActions handleThemeGeneration={handleRedirectAndThemeGeneration} />
|
||||
</HorizontalScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ThemePreviewPanel from "@/components/editor/theme-preview-panel";
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { useAIThemeGenerationResult } from "@/hooks/use-ai-theme-generation";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ImperativePanelGroupHandle } from "react-resizable-panels";
|
||||
import { usePreviewPanel } from "../hooks/use-preview-panel";
|
||||
import { AIChatForm } from "./ai-chat-form";
|
||||
import { ChatHeading } from "./chat-heading";
|
||||
import { ClosableSuggestedPillActions, SuggestedPillActions } from "./suggested-pill-actions";
|
||||
|
||||
export function AIInterface() {
|
||||
const isMobile = useIsMobile();
|
||||
const { themeState } = useEditorStore();
|
||||
const { theme: mode } = useTheme();
|
||||
const { hasPrompted } = useAIThemeGenerationResult();
|
||||
|
||||
// Resizable panel
|
||||
const { isPreviewPanelOpen, setIsPreviewPanelOpen } = usePreviewPanel();
|
||||
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
const [lastOpenLayout, setLastOpenLayout] = useState<number[]>([30, 70]);
|
||||
|
||||
useEffect(() => {
|
||||
const panelGroup = panelGroupRef.current;
|
||||
if (!panelGroup) return;
|
||||
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreviewPanelOpen) {
|
||||
panelGroup.setLayout(lastOpenLayout); // Restore last open layout
|
||||
} else {
|
||||
// Before closing, capture the current layout if it was open
|
||||
const currentLayout = panelGroup.getLayout();
|
||||
if (currentLayout[1] > 1) {
|
||||
// If preview panel had some size
|
||||
setLastOpenLayout(currentLayout);
|
||||
}
|
||||
panelGroup.setLayout([100, 0]); // Collapse preview panel
|
||||
}
|
||||
}, [isPreviewPanelOpen, lastOpenLayout, isMobile]);
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" ref={panelGroupRef} className="isolate">
|
||||
{/* Chat section */}
|
||||
<ResizablePanel
|
||||
defaultSize={isPreviewPanelOpen ? lastOpenLayout[0] : 100}
|
||||
minSize={30}
|
||||
className="z-1 min-w-[max(30%,22rem)]"
|
||||
>
|
||||
<section className="@container relative isolate z-1 mx-auto flex h-full max-w-[49rem] flex-col justify-center px-4">
|
||||
<ChatHeading />
|
||||
|
||||
{/* TODO: Chat messages */}
|
||||
|
||||
{/* Chat form input and suggestions */}
|
||||
<div className="relative mx-auto flex w-full flex-col">
|
||||
<div className="relative isolate z-10 w-full">
|
||||
{hasPrompted && <ClosableSuggestedPillActions />}
|
||||
|
||||
<AIChatForm />
|
||||
|
||||
{/* Background gradients */}
|
||||
<div
|
||||
className={cn(
|
||||
"animate-in fade-in pointer-events-none absolute inset-0 z-[-1] bg-[radial-gradient(ellipse_at_10%_70%,var(--secondary),transparent_15%)] blur-2xl duration-500",
|
||||
hasPrompted ? "opacity-0" : "opacity-100 dark:opacity-50"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"animate-in fade-in pointer-events-none absolute inset-0 z-[-1] bg-[radial-gradient(ellipse_at_90%_30%,var(--primary),transparent_15%)] blur-2xl duration-500",
|
||||
hasPrompted ? "opacity-0" : "opacity-100 dark:opacity-50"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
{!hasPrompted && (
|
||||
<HorizontalScrollArea className="mx-auto pt-4 pb-2">
|
||||
<SuggestedPillActions />
|
||||
</HorizontalScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground truncate py-2 text-center text-xs tracking-tight">
|
||||
tweakcn may make mistakes. Please use with discretion.
|
||||
</p>
|
||||
</section>
|
||||
</ResizablePanel>
|
||||
|
||||
{!isMobile && isPreviewPanelOpen && (
|
||||
<ResizableHandle className="after:hover:bg-muted z-1" />
|
||||
)}
|
||||
|
||||
{/* Preview section */}
|
||||
{!isMobile && (
|
||||
<ResizablePanel
|
||||
defaultSize={isPreviewPanelOpen ? lastOpenLayout[1] : 0}
|
||||
maxSize={70}
|
||||
minSize={isPreviewPanelOpen ? 40 : 0}
|
||||
className="z-2"
|
||||
>
|
||||
<section
|
||||
className={cn("isolate z-2 flex h-full flex-col overflow-hidden max-md:hidden")}
|
||||
>
|
||||
<ThemePreviewPanel styles={themeState.styles} currentMode={mode} />
|
||||
</section>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<Drawer open={isMobile && isPreviewPanelOpen} onOpenChange={setIsPreviewPanelOpen}>
|
||||
<DrawerContent className="flex max-h-[calc(90svh)] md:hidden">
|
||||
<DrawerTitle className="sr-only">Preview Panel</DrawerTitle>
|
||||
|
||||
<section className="isolate flex flex-col overflow-hidden">
|
||||
{isMobile && <ThemePreviewPanel styles={themeState.styles} currentMode={mode} />}
|
||||
</section>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import Logo from "@/assets/logo.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { Theme, ThemePreset } from "@/types/theme";
|
||||
import { Moon, MoreVertical, Sun } from "lucide-react";
|
||||
|
||||
// This is repeating from `dashboard/components/theme-card.tsx`
|
||||
type SwatchDefinition = {
|
||||
name: string; // Text to display on hover
|
||||
bgKey: keyof Theme["styles"]["light" | "dark"]; // Key for background color
|
||||
fgKey: keyof Theme["styles"]["light" | "dark"]; // Key for text color
|
||||
};
|
||||
|
||||
// This is repeating from `dashboard/components/theme-card.tsx`
|
||||
const swatchDefinitions: SwatchDefinition[] = [
|
||||
{ name: "Primary", bgKey: "primary", fgKey: "primary-foreground" },
|
||||
{ name: "Secondary", bgKey: "secondary", fgKey: "secondary-foreground" },
|
||||
{ name: "Accent", bgKey: "accent", fgKey: "accent-foreground" },
|
||||
{ name: "Muted", bgKey: "muted", fgKey: "muted-foreground" },
|
||||
// Special case: Background swatch shows "Foreground" text using the main foreground color
|
||||
{ name: "Background", bgKey: "background", fgKey: "foreground" },
|
||||
];
|
||||
|
||||
export function CommunityThemeCard({ themePreset }: { themePreset: ThemePreset }) {
|
||||
const { themeState } = useEditorStore();
|
||||
const mode = themeState.currentMode;
|
||||
|
||||
return (
|
||||
<div className="group/card relative flex flex-col gap-2">
|
||||
<div className="group/preview relative flex h-56 overflow-hidden rounded-lg border">
|
||||
<div className="from-foreground/20 to-foreground-10 absolute inset-0 z-1 flex flex-col items-center justify-center gap-2 bg-gradient-to-b opacity-0 transition-opacity duration-300 ease-in-out group-hover/preview:opacity-100">
|
||||
<Button variant="outline" size="sm" className="w-28 drop-shadow">
|
||||
View Details
|
||||
</Button>
|
||||
<Button size="sm" className="w-28 drop-shadow">
|
||||
View in Editor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TEMPORARY CARD IMPLEMENTATION: Based on `dashboard/components/theme-card.tsx` */}
|
||||
<div className="relative isolate flex flex-1 flex-col">
|
||||
{/* Light mode swatches */}
|
||||
<div className="group relative flex flex-1">
|
||||
<div className="absolute top-2 left-2 z-10 size-6">
|
||||
<Sun className="size-full opacity-80 drop-shadow-2xl transition-opacity group-hover/preview:opacity-100" />
|
||||
</div>
|
||||
{swatchDefinitions.map((swatch) => (
|
||||
<div
|
||||
key={swatch.name + swatch.bgKey + "light"}
|
||||
className={cn(
|
||||
"group/swatch relative h-full flex-1 transition-all duration-300 ease-in-out"
|
||||
)}
|
||||
style={{ backgroundColor: themePreset.styles.light[swatch.bgKey] }}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dark mode swatches */}
|
||||
<div className="group relative flex flex-1">
|
||||
<div className="absolute right-2 bottom-2 z-10 size-6">
|
||||
<Moon className="size-full opacity-80 drop-shadow-2xl transition-opacity group-hover/preview:opacity-100" />
|
||||
</div>
|
||||
{swatchDefinitions.map((swatch) => (
|
||||
<div
|
||||
key={swatch.name + swatch.bgKey + "dark"}
|
||||
className={cn(
|
||||
"group/swatch relative h-full flex-1 transition-all duration-300 ease-in-out"
|
||||
)}
|
||||
style={{ backgroundColor: themePreset.styles.dark[swatch.bgKey] }}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="from-primary to-secondary aspect-square size-10 rounded-full border bg-linear-150/oklch"
|
||||
style={
|
||||
{
|
||||
"--primary": themePreset.styles[mode].primary,
|
||||
"--primary-foreground": themePreset.styles[mode]["primary-foreground"],
|
||||
"--secondary": themePreset.styles[mode].secondary,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Logo className="text-primary-foreground size-full p-2 drop-shadow-lg" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="line-clamp-1 text-sm leading-none font-medium">{themePreset.label}</h3>
|
||||
<p className="text-muted-foreground line-clamp-1 text-xs">
|
||||
{themePreset.createdAt ?? "Unknown creation date"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" className="ml-auto">
|
||||
<MoreVertical />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityThemeCardSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="aspect-square size-10 rounded-full" />
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Skeleton className="line-clamp-1 h-4 w-1/2 rounded-lg leading-none font-medium" />
|
||||
<Skeleton className="text-muted-foreground h-3 w-1/3 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<MoreVertical className="text-muted ml-auto animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import { CommunityThemeCard, CommunityThemeCardSkeleton } from "./community-theme-card";
|
||||
import { ThemePreset } from "@/types/theme";
|
||||
import { defaultPresets } from "@/utils/theme-presets";
|
||||
|
||||
// TODO: Remove this once we have a real API to fetch the community themes
|
||||
const getDefaultThemePresets = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return defaultPresets;
|
||||
};
|
||||
|
||||
export async function CommunityThemes() {
|
||||
const themePresetsPromise = getDefaultThemePresets();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">From the Community</h2>
|
||||
<Button variant="link" className="h-fit gap-1 p-0 [&>svg]:size-3">
|
||||
View All <ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Explore the themes the community is creating with tweakcn.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<CommunityThemeCardSkeleton />
|
||||
<CommunityThemeCardSkeleton />
|
||||
<CommunityThemeCardSkeleton />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CommunityThemeCards themePresetsPromise={themePresetsPromise} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommunityThemeCardsProps {
|
||||
themePresetsPromise: Promise<Record<string, ThemePreset>>;
|
||||
}
|
||||
|
||||
export async function CommunityThemeCards({ themePresetsPromise }: CommunityThemeCardsProps) {
|
||||
const themePresets = await themePresetsPromise;
|
||||
const presets = Object.entries(themePresets).reduce(
|
||||
(acc, [id, preset]) => {
|
||||
acc[id] = {
|
||||
label: preset.label,
|
||||
styles: preset.styles,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ThemePreset>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.values(presets).map((preset) => (
|
||||
<CommunityThemeCard key={preset.label} themePreset={preset} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { createCurrentThemePromptJson, PROMPTS } from "@/utils/prompts";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { usePreviewPanel } from "../hooks/use-preview-panel";
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { PROMPTS } from "@/utils/prompts";
|
||||
import { createCurrentThemePrompt } from "@/utils/ai-prompt";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { AIPillActionButton } from "@/components/editor/ai/ai-pill-action-button";
|
||||
|
||||
export function ClosableSuggestedPillActions() {
|
||||
const [hasClosedSuggestions, setHasClosedSuggestions] = useState(false);
|
||||
|
||||
if (hasClosedSuggestions) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
{/* Fade out effect when scrolling */}
|
||||
<div className="via-background/50 from-background pointer-events-none absolute -top-8 right-4 left-0 z-20 h-8 bg-gradient-to-t to-transparent opacity-100 transition-opacity ease-out" />
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<h3 className="text-muted-foreground text-xs">Suggestions</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 [&>svg]:size-3"
|
||||
onClick={() => setHasClosedSuggestions(true)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<HorizontalScrollArea className="py-2">
|
||||
<SuggestedPillActions />
|
||||
</HorizontalScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SuggestedPillActions() {
|
||||
const { generateTheme } = useAIThemeGeneration();
|
||||
const { setIsPreviewPanelOpen } = usePreviewPanel();
|
||||
const { openAuthDialog } = useAuthStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
export function SuggestedPillActions({
|
||||
handleThemeGeneration,
|
||||
}: {
|
||||
handleThemeGeneration: (promptData: AIPromptData | null) => void;
|
||||
}) {
|
||||
const { loading: aiIsGenerating } = useAIThemeGeneration();
|
||||
|
||||
const handleSetPrompt = async (prompt: string) => {
|
||||
const jsonPrompt = createCurrentThemePromptJson({ prompt });
|
||||
const stringifiedJsonPrompt = JSON.stringify(jsonPrompt);
|
||||
|
||||
if (!session) {
|
||||
openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", {
|
||||
prompt,
|
||||
jsonPrompt: stringifiedJsonPrompt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await generateTheme({
|
||||
prompt,
|
||||
jsonPrompt: stringifiedJsonPrompt,
|
||||
onSuccess: () => {
|
||||
setIsPreviewPanelOpen(true);
|
||||
},
|
||||
});
|
||||
const promptData = createCurrentThemePrompt({ prompt });
|
||||
handleThemeGeneration(promptData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(PROMPTS).map(([key, { label, prompt }]) => (
|
||||
<PillButton key={key} onClick={() => handleSetPrompt(prompt)}>
|
||||
<AIPillActionButton
|
||||
key={key}
|
||||
onClick={() => handleSetPrompt(prompt)}
|
||||
disabled={aiIsGenerating}
|
||||
>
|
||||
<Sparkles /> {label}
|
||||
</PillButton>
|
||||
</AIPillActionButton>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PillButtonProps extends ComponentProps<typeof Button> {}
|
||||
|
||||
function PillButton({ className, children, ...props }: PillButtonProps) {
|
||||
const { loading: aiGenerateLoading } = useAIThemeGeneration();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/pill relative active:scale-95",
|
||||
aiGenerateLoading && "pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div className="from-primary to-background absolute inset-0 z-[-1] rounded-full bg-gradient-to-br opacity-0 transition-all duration-150 ease-in group-hover/pill:opacity-30" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 text-muted-foreground hover:text-foreground border-border/80! rounded-full border bg-transparent px-2 font-medium text-nowrap whitespace-nowrap backdrop-blur-md transition-all duration-150 ease-in select-none focus:outline-none",
|
||||
"group-hover/pill:inset-shadow-primary/50 h-7 inset-shadow-2xs inset-shadow-transparent [&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
disabled={aiGenerateLoading}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { usePreviewPanel } from "../hooks/use-preview-panel";
|
||||
|
||||
interface TogglePreviewPanelButtonProps extends React.ComponentProps<typeof Button> {}
|
||||
|
||||
export function TogglePreviewPanelButton({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: TogglePreviewPanelButtonProps) {
|
||||
const { isPreviewPanelOpen, togglePreviewPanel } = usePreviewPanel();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile && !isPreviewPanelOpen) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(className)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={togglePreviewPanel}
|
||||
{...props}
|
||||
>
|
||||
<Eye />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(className)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={togglePreviewPanel}
|
||||
{...props}
|
||||
>
|
||||
{!isPreviewPanelOpen ? <Eye /> : <EyeOff />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { DialogActionsProvider, useDialogActions } from "@/hooks/use-dialog-actions";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { Code, Edit, Heart, Moon, Redo, RefreshCw, Sun, Undo } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePreviewPanel } from "../hooks/use-preview-panel";
|
||||
import { TogglePreviewPanelButton } from "./toggle-preview-panel-button";
|
||||
|
||||
export function Toolbar() {
|
||||
return (
|
||||
<DialogActionsProvider>
|
||||
<div className="h-14 content-center overflow-hidden border-b">
|
||||
<HorizontalScrollArea className="isolate mx-auto flex h-full w-full justify-center px-4 py-2">
|
||||
<ToolbarActions />
|
||||
</HorizontalScrollArea>
|
||||
</div>
|
||||
</DialogActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarActions() {
|
||||
const { resetToCurrentPreset, hasUnsavedChanges, redo, undo, canRedo, canUndo } =
|
||||
useEditorStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const router = useRouter();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { loading: aiGenerateLoading } = useAIThemeGeneration();
|
||||
const { isPreviewPanelOpen } = usePreviewPanel();
|
||||
const { handleSaveClick, setCodePanelOpen } = useDialogActions();
|
||||
|
||||
const handleOpenInEditor = () => {
|
||||
router.push("/editor/theme");
|
||||
};
|
||||
|
||||
const handleThemeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { clientX: x, clientY: y } = event;
|
||||
toggleTheme({ x, y });
|
||||
};
|
||||
|
||||
const handleOpenCodePanel = () => {
|
||||
setCodePanelOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenSaveDialog = () => {
|
||||
handleSaveClick({ shareAfterSave: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Open in Editor Button */}
|
||||
<TooltipWrapper label="Customize further in Editor" asChild>
|
||||
<Button
|
||||
size={isMobile ? "icon" : "default"}
|
||||
variant="secondary"
|
||||
className="border shadow-none"
|
||||
onClick={handleOpenInEditor}
|
||||
disabled={aiGenerateLoading}
|
||||
>
|
||||
<Edit />
|
||||
<span className="hidden md:block">Open in Editor</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
{/* Theme Toggle Button */}
|
||||
<TooltipWrapper label="Toggle theme" asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="bg-background border"
|
||||
disabled={aiGenerateLoading}
|
||||
onClick={handleThemeToggle}
|
||||
>
|
||||
{theme === "light" ? <Moon /> : <Sun />}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
{/* Undo Button */}
|
||||
<TooltipWrapper label="Undo" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background border"
|
||||
onClick={undo}
|
||||
disabled={!canUndo() || aiGenerateLoading}
|
||||
>
|
||||
<Undo />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
{/* Redo Button */}
|
||||
<TooltipWrapper label="Redo" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background border"
|
||||
onClick={redo}
|
||||
disabled={!canRedo() || aiGenerateLoading}
|
||||
>
|
||||
<Redo />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
{/* Reset Button */}
|
||||
<TooltipWrapper label="Reset to preset defaults" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "icon" : "default"}
|
||||
className="bg-background border"
|
||||
onClick={resetToCurrentPreset}
|
||||
disabled={!hasUnsavedChanges() || aiGenerateLoading}
|
||||
>
|
||||
<RefreshCw /> <span className="hidden md:block">Reset</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
{/* Save Button */}
|
||||
<TooltipWrapper label="Save theme" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "icon" : "default"}
|
||||
className="bg-background border"
|
||||
onClick={handleOpenSaveDialog}
|
||||
disabled={aiGenerateLoading}
|
||||
>
|
||||
<Heart /> <span className="hidden md:block">Save</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
{/* Code Button */}
|
||||
<TooltipWrapper label="View theme code" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobile ? "icon" : "default"}
|
||||
className="bg-background border"
|
||||
onClick={handleOpenCodePanel}
|
||||
disabled={aiGenerateLoading}
|
||||
>
|
||||
<Code /> <span className="hidden md:block">Code</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper
|
||||
label={isPreviewPanelOpen ? "Hide preview panel" : "Show preview panel"}
|
||||
asChild
|
||||
>
|
||||
<TogglePreviewPanelButton variant="ghost" className="border" />
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface PreviewPanelContextType {
|
||||
isPreviewPanelOpen: boolean;
|
||||
togglePreviewPanel: () => void;
|
||||
setIsPreviewPanelOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const PreviewPanelContext = createContext<PreviewPanelContextType | undefined>(undefined);
|
||||
|
||||
export function PreviewPanelProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isPreviewPanelOpen, setIsPreviewPanelOpen] = useState(false);
|
||||
|
||||
const togglePreviewPanel = () => {
|
||||
console.log("Toggling preview panel");
|
||||
setIsPreviewPanelOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<PreviewPanelContext.Provider
|
||||
value={{
|
||||
isPreviewPanelOpen,
|
||||
togglePreviewPanel,
|
||||
setIsPreviewPanelOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PreviewPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePreviewPanel() {
|
||||
const context = useContext(PreviewPanelContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("usePreviewPanel must be used within a PreviewPanelProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
+3
-5
@@ -2,11 +2,9 @@ import { Header } from "@/components/header";
|
||||
|
||||
export default function AiLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative isolate flex h-svh flex-col">
|
||||
<div className="z-1">
|
||||
<Header />
|
||||
</div>
|
||||
<main className="isolate z-2 flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
<div className="relative isolate flex min-h-svh flex-col">
|
||||
<Header />
|
||||
<main className="isolate flex flex-1 flex-col overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-8
@@ -1,8 +1,7 @@
|
||||
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
|
||||
import { Metadata } from "next";
|
||||
import { AIInterface } from "./components/ai-interface";
|
||||
import { Toolbar } from "./components/toolbar";
|
||||
import { PreviewPanelProvider } from "./hooks/use-preview-panel";
|
||||
import { AIAnnouncement } from "./components/ai-announcement";
|
||||
import { AIChatHero } from "./components/ai-chat-hero";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Theme Editor for shadcn/ui — tweakcn",
|
||||
@@ -15,12 +14,15 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function AiPage() {
|
||||
return (
|
||||
<PreviewPanelProvider>
|
||||
<Toolbar />
|
||||
<>
|
||||
<AuthDialogWrapper />
|
||||
<div className="relative isolate flex flex-1 flex-col overflow-hidden">
|
||||
<AIInterface />
|
||||
<div className="relative isolate container mx-auto flex flex-1 flex-col gap-24 overflow-x-visible overflow-y-auto px-4 md:px-6">
|
||||
{/* AI Chat entry point section */}
|
||||
<section className="relative isolate flex flex-col gap-4 pt-28 lg:pt-44">
|
||||
<AIAnnouncement />
|
||||
<AIChatHero />
|
||||
</section>
|
||||
</div>
|
||||
</PreviewPanelProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { getEditorConfig } from "@/config/editors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Editor from "@/components/editor/editor";
|
||||
import { Metadata } from "next";
|
||||
import { Header } from "@/components/header";
|
||||
import { getTheme } from "@/actions/themes";
|
||||
import { Suspense } from "react";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
|
||||
import Editor from "@/components/editor/editor";
|
||||
import { Header } from "@/components/header";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "tweakcn — Theme Generator for shadcn/ui",
|
||||
@@ -14,30 +13,22 @@ export const metadata: Metadata = {
|
||||
"Easily customize and preview your shadcn/ui theme with tweakcn. Modify colors, fonts, and styles in real-time.",
|
||||
};
|
||||
|
||||
export default async function Component({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ themeId: string[] }>;
|
||||
}) {
|
||||
export default async function Component({ params }: { params: Promise<{ themeId: string[] }> }) {
|
||||
const { themeId } = await params;
|
||||
const themePromise =
|
||||
themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null);
|
||||
const themePromise = themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"h-screen flex flex-col text-foreground bg-background transition-colors"
|
||||
"text-foreground bg-background flex h-svh flex-col overflow-hidden transition-colors"
|
||||
)}
|
||||
>
|
||||
<AuthDialogWrapper />
|
||||
<Header />
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Editor
|
||||
config={getEditorConfig("theme")}
|
||||
themePromise={themePromise}
|
||||
/>
|
||||
<Editor themePromise={themePromise} />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+10
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, CopyCheck } from "lucide-react";
|
||||
import { ComponentProps } from "react";
|
||||
import { TooltipWrapper } from "./tooltip-wrapper";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface CopyButtonProps extends ComponentProps<typeof Button> {
|
||||
textToCopy: string;
|
||||
successMessage?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CopyButton({ textToCopy, successMessage, className, ...props }: CopyButtonProps) {
|
||||
const { copyToClipboard, hasCopied } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<TooltipWrapper label="Copy" asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn("size-6 [&>svg]:size-3.5", className)}
|
||||
onClick={() => copyToClipboard(textToCopy, successMessage)}
|
||||
{...props}
|
||||
>
|
||||
{hasCopied ? <CopyCheck /> : <Copy />}
|
||||
<span className="sr-only">Copy</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons";
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { DialogActionsProvider, useDialogActions } from "@/hooks/use-dialog-actions";
|
||||
|
||||
export function ActionBar() {
|
||||
@@ -12,27 +13,20 @@ export function ActionBar() {
|
||||
}
|
||||
|
||||
function ActionBarContent() {
|
||||
const {
|
||||
isCreatingTheme,
|
||||
handleSaveClick,
|
||||
handleAiGenerateClick,
|
||||
handleShareClick,
|
||||
setCssImportOpen,
|
||||
setCodePanelOpen,
|
||||
} = useDialogActions();
|
||||
const { isCreatingTheme, handleSaveClick, handleShareClick, setCssImportOpen, setCodePanelOpen } =
|
||||
useDialogActions();
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="flex h-14 items-center justify-end gap-4 px-4">
|
||||
<HorizontalScrollArea className="flex h-14 w-full items-center justify-end gap-4 px-4">
|
||||
<ActionBarButtons
|
||||
onImportClick={() => setCssImportOpen(true)}
|
||||
onCodeClick={() => setCodePanelOpen(true)}
|
||||
onSaveClick={() => handleSaveClick()}
|
||||
onAiGenerateClick={handleAiGenerateClick}
|
||||
isSaving={isCreatingTheme}
|
||||
onShareClick={handleShareClick}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { ImportButton } from "./import-button";
|
||||
import { ResetButton } from "./reset-button";
|
||||
import { SaveButton } from "./save-button";
|
||||
import { CodeButton } from "./code-button";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { CodeButton } from "./code-button";
|
||||
import { EditButton } from "./edit-button";
|
||||
import { ImportButton } from "./import-button";
|
||||
import { MoreOptions } from "./more-options";
|
||||
import { AIGenerateButton } from "./ai-generate-button";
|
||||
import { ResetButton } from "./reset-button";
|
||||
import { SaveButton } from "./save-button";
|
||||
import { ShareButton } from "./share-button";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { UndoRedoButtons } from "./undo-redo-buttons";
|
||||
import { useAIChatStore } from "@/store/ai-chat-store";
|
||||
|
||||
interface ActionBarButtonsProps {
|
||||
onImportClick: () => void;
|
||||
onCodeClick: () => void;
|
||||
onSaveClick: () => void;
|
||||
onAiGenerateClick: () => void;
|
||||
onShareClick: (id?: string) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
@@ -25,34 +25,40 @@ export function ActionBarButtons({
|
||||
onImportClick,
|
||||
onCodeClick,
|
||||
onSaveClick,
|
||||
onAiGenerateClick,
|
||||
onShareClick,
|
||||
isSaving,
|
||||
}: ActionBarButtonsProps) {
|
||||
const { themeState, resetToCurrentPreset, hasUnsavedChanges } = useEditorStore();
|
||||
|
||||
const { loading: aiGenerationLoading } = useAIThemeGeneration();
|
||||
const { getPreset } = useThemePresetStore();
|
||||
const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined;
|
||||
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
|
||||
const { clearMessages } = useAIChatStore();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToCurrentPreset();
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<MoreOptions />
|
||||
<MoreOptions disabled={aiGenerationLoading} />
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<UndoRedoButtons />
|
||||
<UndoRedoButtons disabled={aiGenerationLoading} />
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<ResetButton onClick={handleReset} disabled={!hasUnsavedChanges() || aiGenerationLoading} />
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<ResetButton onReset={resetToCurrentPreset} isDisabled={!hasUnsavedChanges()} />
|
||||
<ImportButton onImportClick={onImportClick} />
|
||||
<ImportButton onClick={onImportClick} disabled={aiGenerationLoading} />
|
||||
</div>
|
||||
<AIGenerateButton onClick={onAiGenerateClick} />
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
{isSavedPreset && <EditButton themeId={themeState.preset as string} />}
|
||||
<ShareButton onShareClick={() => onShareClick(themeState.preset)} />
|
||||
<SaveButton onSaveClick={onSaveClick} isSaving={isSaving} />
|
||||
<CodeButton onCodeClick={onCodeClick} />
|
||||
{isSavedPreset && (
|
||||
<EditButton themeId={themeState.preset as string} disabled={aiGenerationLoading} />
|
||||
)}
|
||||
<ShareButton onClick={() => onShareClick(themeState.preset)} disabled={aiGenerationLoading} />
|
||||
<SaveButton onClick={onSaveClick} isSaving={isSaving} disabled={aiGenerationLoading} />
|
||||
<CodeButton onClick={onCodeClick} disabled={aiGenerationLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import { Loading } from "@/components/loading";
|
||||
|
||||
const CustomTextarea = lazy(
|
||||
() => import("@/components/editor/custom-textarea")
|
||||
);
|
||||
|
||||
interface AIGenerateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
loading: boolean;
|
||||
onGenerate: (textPrompt: string, jsonPrompt: string) => void;
|
||||
}
|
||||
|
||||
export function AIGenerateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
loading,
|
||||
onGenerate,
|
||||
}: AIGenerateDialogProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [jsonPrompt, setJsonPrompt] = useState("");
|
||||
|
||||
const handleContentChange = (
|
||||
textContent: string,
|
||||
jsonContent: JSONContent
|
||||
) => {
|
||||
setJsonPrompt(JSON.stringify(jsonContent));
|
||||
setPrompt(textContent);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
onGenerate(prompt, jsonPrompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[550px] p-0 pt-6 overflow-hidden rounded-lg border shadow-lg gap-6">
|
||||
<DialogHeader className="px-6">
|
||||
<DialogTitle>How can I help you theme?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use <span className="font-medium">@</span> to mention a preset or
|
||||
your current theme.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6">
|
||||
<div className="bg-muted/40 rounded-lg p-1 max-w-[500px]">
|
||||
<Suspense fallback={<Loading className="min-h-[60px]" />}>
|
||||
<CustomTextarea
|
||||
onContentChange={handleContentChange}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Try{" "}
|
||||
<em className="text-foreground font-medium">
|
||||
@Modern Minimal but in red
|
||||
</em>{" "}
|
||||
or{" "}
|
||||
<em className="text-foreground font-medium">
|
||||
Make the @Current Theme high contrast
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-muted/30 px-6 py-4 border-t">
|
||||
<div className="flex items-center justify-end w-full gap-2">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || loading}
|
||||
className="gap-1"
|
||||
size="sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Create Theme
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
import { Braces } from "lucide-react";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Braces } from "lucide-react";
|
||||
|
||||
interface CodeButtonProps {
|
||||
onCodeClick: () => void;
|
||||
}
|
||||
interface CodeButtonProps extends React.ComponentProps<typeof Button> {}
|
||||
|
||||
export function CodeButton({ onCodeClick }: CodeButtonProps) {
|
||||
export function CodeButton({ className, ...props }: CodeButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2"
|
||||
onClick={onCodeClick}
|
||||
>
|
||||
<Braces className="size-3.5" />
|
||||
<span className="hidden text-sm md:block">Code</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View theme code</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipWrapper label="View theme code" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Braces className="size-3.5" />
|
||||
<span className="hidden text-sm md:block">Code</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
import { PenLine } from "lucide-react";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PenLine } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
interface EditButtonProps {
|
||||
interface EditButtonProps extends React.ComponentProps<typeof Button> {
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
export function EditButton({ themeId }: EditButtonProps) {
|
||||
export function EditButton({ themeId, disabled, className, ...props }: EditButtonProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const isEditing = pathname.includes(themeId);
|
||||
|
||||
// Keep the current search params for tab persistence
|
||||
const href = `/editor/theme/${themeId}?${searchParams}`;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={`/editor/theme/${themeId}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
>
|
||||
<PenLine className="size-3.5" />
|
||||
<span className="text-sm hidden md:block">Edit</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit theme</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipWrapper label="Edit theme" asChild>
|
||||
<Link href={href}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isEditing}
|
||||
{...props}
|
||||
>
|
||||
<PenLine className="size-3.5" />
|
||||
<span className="hidden text-sm md:block">Edit</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { FileCode } from "lucide-react";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileCode } from "lucide-react";
|
||||
|
||||
interface ImportButtonProps {
|
||||
onImportClick: () => void;
|
||||
}
|
||||
interface ImportButtonProps extends React.ComponentProps<typeof Button> {}
|
||||
|
||||
export function ImportButton({ onImportClick }: ImportButtonProps) {
|
||||
export function ImportButton({ className, ...props }: ImportButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
onClick={onImportClick}
|
||||
>
|
||||
<FileCode className="size-3.5" />
|
||||
<span className="text-sm hidden md:block">Import</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Import CSS variables</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipWrapper label="Import CSS variables" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<FileCode className="size-3.5" />
|
||||
<span className="hidden text-sm md:block">Import</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import McpIcon from "@/assets/mcp.svg";
|
||||
import ContrastChecker from "@/components/editor/contrast-checker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { MCPDialog } from "./mcp-dialog";
|
||||
import McpIcon from "@/assets/mcp.svg";
|
||||
import ContrastChecker from "@/components/editor/contrast-checker";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { MCPDialog } from "./mcp-dialog";
|
||||
|
||||
export function MoreOptions() {
|
||||
interface MoreOptionsProps extends React.ComponentProps<typeof DropdownMenuTrigger> {}
|
||||
|
||||
export function MoreOptions({ ...props }: MoreOptionsProps) {
|
||||
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
||||
const { themeState } = useEditorStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -30,9 +32,7 @@ export function MoreOptions() {
|
||||
MCP
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<ContrastChecker
|
||||
currentStyles={themeState.styles[themeState.currentMode]}
|
||||
/>
|
||||
<ContrastChecker currentStyles={themeState.styles[themeState.currentMode]} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface ResetButtonProps {
|
||||
onReset: () => void;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
interface ResetButtonProps extends React.ComponentProps<typeof Button> {}
|
||||
|
||||
export function ResetButton({ onReset, isDisabled }: ResetButtonProps) {
|
||||
export function ResetButton({ className, ...props }: ResetButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
onClick={onReset}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
<span className="text-sm hidden md:block">Reset</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset to preset defaults</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipWrapper label="Reset to preset defaults" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
<span className="hidden text-sm md:block">Reset</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { Heart, Loader2 } from "lucide-react";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Heart, Loader2 } from "lucide-react";
|
||||
|
||||
interface SaveButtonProps {
|
||||
onSaveClick: () => void;
|
||||
interface SaveButtonProps extends React.ComponentProps<typeof Button> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function SaveButton({ onSaveClick, isSaving }: SaveButtonProps) {
|
||||
export function SaveButton({ isSaving, disabled, className, ...props }: SaveButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2"
|
||||
onClick={onSaveClick}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Heart className="size-3.5" />
|
||||
)}
|
||||
<span className="hidden text-sm md:block">Save</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Save theme</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipWrapper label="Save theme" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
|
||||
className
|
||||
)}
|
||||
disabled={isSaving || disabled}
|
||||
{...props}
|
||||
>
|
||||
{isSaving ? <Loader2 className="size-3.5 animate-spin" /> : <Heart className="size-3.5" />}
|
||||
<span className="hidden text-sm md:block">Save</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import { Loader2, Share2 } from "lucide-react";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2, Share2 } from "lucide-react";
|
||||
|
||||
interface ShareButtonProps {
|
||||
onShareClick: () => void;
|
||||
interface ShareButtonProps extends React.ComponentProps<typeof Button> {
|
||||
isSharing?: boolean;
|
||||
}
|
||||
|
||||
export function ShareButton({ onShareClick, isSharing }: ShareButtonProps) {
|
||||
export function ShareButton({
|
||||
onClick,
|
||||
isSharing,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: ShareButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
onClick={onShareClick}
|
||||
disabled={isSharing}
|
||||
>
|
||||
{isSharing ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="size-3.5" />
|
||||
)}
|
||||
<span className="text-sm hidden md:block">Share</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Share theme</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipWrapper label="Share theme" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent/50 h-8 gap-1.5 px-2",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={isSharing || disabled}
|
||||
{...props}
|
||||
>
|
||||
{isSharing ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="size-3.5" />
|
||||
)}
|
||||
<span className="hidden text-sm md:block">Share</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { Redo, Undo } from "lucide-react";
|
||||
|
||||
export function UndoRedoButtons() {
|
||||
interface UndoRedoButtonsProps extends React.ComponentProps<typeof Button> {}
|
||||
|
||||
export function UndoRedoButtons({ disabled, ...props }: UndoRedoButtonsProps) {
|
||||
const { undo, redo, canUndo, canRedo } = useEditorStore();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={undo} disabled={!canUndo()}>
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Undo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={redo} disabled={!canRedo()}>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Redo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipWrapper label="Undo" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled || !canUndo()}
|
||||
{...props}
|
||||
onClick={undo}
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper label="Redo" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled || !canRedo()}
|
||||
{...props}
|
||||
onClick={redo}
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Loading } from "@/components/loading";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAIChatStore } from "@/store/ai-chat-store";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { ArrowUp, Loader, Plus, StopCircle } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
|
||||
const CustomTextarea = dynamic(() => import("@/components/editor/custom-textarea"), {
|
||||
ssr: false,
|
||||
loading: () => <Loading className="min-h-[60px] w-full rounded-lg" />,
|
||||
});
|
||||
|
||||
export function AIChatForm({
|
||||
handleThemeGeneration,
|
||||
}: {
|
||||
handleThemeGeneration: (promptData: AIPromptData | null) => void;
|
||||
}) {
|
||||
const [promptData, setPromptData] = useState<AIPromptData | null>(null);
|
||||
const { loading: aiGenerateLoading, cancelThemeGeneration } = useAIThemeGeneration();
|
||||
|
||||
const { messages, clearMessages } = useAIChatStore();
|
||||
|
||||
const handleContentChange = (newPromptData: AIPromptData) => {
|
||||
setPromptData(newPromptData);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!promptData?.content) return;
|
||||
|
||||
handleThemeGeneration(promptData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="@container/form relative transition-all">
|
||||
<div className="bg-background relative z-10 flex size-full min-h-[100px] flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-xs">
|
||||
<label className="sr-only">Chat Input</label>
|
||||
<div className="min-h-[60px] p-2 pb-0">
|
||||
<div className="bg-muted/40 relative isolate rounded-lg">
|
||||
<CustomTextarea
|
||||
onContentChange={handleContentChange}
|
||||
onGenerate={handleGenerate}
|
||||
key={messages.length}
|
||||
characterLimit={AI_PROMPT_CHARACTER_LIMIT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
||||
<TooltipWrapper label="Create new chat" asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearMessages}
|
||||
disabled={aiGenerateLoading || messages.length === 0}
|
||||
className="shadow-none"
|
||||
>
|
||||
<Plus />
|
||||
<span>New chat</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* TODO: Add image upload */}
|
||||
{aiGenerateLoading ? (
|
||||
<TooltipWrapper label="Cancel generation" asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={cancelThemeGeneration}
|
||||
className={cn("flex items-center gap-1 shadow-none", "@max-[350px]/form:w-8")}
|
||||
>
|
||||
<StopCircle />
|
||||
<span className="hidden @[350px]/form:inline-flex">Stop</span>
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
) : (
|
||||
<TooltipWrapper label="Send message" asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="size-8 shadow-none"
|
||||
onClick={handleGenerate}
|
||||
disabled={!promptData?.content || aiGenerateLoading}
|
||||
>
|
||||
{aiGenerateLoading ? <Loader className="animate-spin" /> : <ArrowUp />}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { usePostLoginAction } from "@/hooks/use-post-login-action";
|
||||
import { buildPrompt } from "@/lib/ai-theme-generator";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAIChatStore } from "@/store/ai-chat-store";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { attachLastGeneratedThemeMention, mentionsCount } from "@/utils/ai-prompt";
|
||||
import dynamic from "next/dynamic";
|
||||
import { AIChatForm } from "./ai-chat-form";
|
||||
import { ClosableSuggestedPillActions } from "./closeable-suggested-pill-actions";
|
||||
|
||||
const ChatMessages = dynamic(() => import("./chat-messages").then((mod) => mod.ChatMessages), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const NoMessagesPlaceholder = dynamic(
|
||||
() => import("./no-messages-placeholder").then((mod) => mod.NoMessagesPlaceholder),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export function AIInterface() {
|
||||
const { generateTheme } = useAIThemeGeneration();
|
||||
const { messages, addUserMessage, addAssistantMessage, resetMessagesUpToIndex } =
|
||||
useAIChatStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { openAuthDialog } = useAuthStore();
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const handleThemeGeneration = async (promptData: AIPromptData | null) => {
|
||||
if (!session) {
|
||||
openAuthDialog("signup", "AI_GENERATE_FROM_CHAT", { promptData });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptData) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate theme. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let transformedPromptData = promptData;
|
||||
|
||||
if (mentionsCount(promptData) === 0) {
|
||||
transformedPromptData = attachLastGeneratedThemeMention(promptData);
|
||||
}
|
||||
|
||||
addUserMessage({
|
||||
promptData: transformedPromptData,
|
||||
});
|
||||
|
||||
const result = await generateTheme(buildPrompt(transformedPromptData));
|
||||
|
||||
if (!result) {
|
||||
addAssistantMessage({
|
||||
content: "Failed to generate theme.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
addAssistantMessage({
|
||||
content:
|
||||
result?.text ??
|
||||
(result?.theme ? "Here's the theme I generated for you." : "Failed to generate theme."),
|
||||
themeStyles: result?.theme,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetry = async (messageIndex: number) => {
|
||||
const messageToRetry = messages[messageIndex];
|
||||
|
||||
if (!messageToRetry || messageToRetry.role !== "user" || !messageToRetry.promptData) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Cannot retry this message.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset messages up to the retry point (remove the user message and any subsequent messages)
|
||||
resetMessagesUpToIndex(messageIndex);
|
||||
|
||||
// Resend the prompt
|
||||
await handleThemeGeneration(messageToRetry.promptData);
|
||||
};
|
||||
|
||||
usePostLoginAction("AI_GENERATE_FROM_CHAT", ({ promptData }) => {
|
||||
handleThemeGeneration(promptData);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="@container relative isolate z-1 mx-auto flex h-full w-full max-w-[49rem] flex-1 flex-col justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col overflow-y-hidden transition-all duration-300 ease-out"
|
||||
)}
|
||||
>
|
||||
{hasMessages ? (
|
||||
<ChatMessages onRetry={handleRetry} />
|
||||
) : (
|
||||
<div className="animate-in fade-in-50 zoom-in-95 relative isolate px-4 pt-8 duration-300 ease-out sm:pt-16 md:pt-24">
|
||||
<NoMessagesPlaceholder handleThemeGeneration={handleThemeGeneration} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat form input and suggestions */}
|
||||
<div className="relative mx-auto flex w-full flex-col px-4 pb-4">
|
||||
<div className="relative isolate z-10 w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all ease-out",
|
||||
hasMessages ? "scale-100 opacity-100" : "h-0 scale-80 opacity-0"
|
||||
)}
|
||||
>
|
||||
<ClosableSuggestedPillActions handleThemeGeneration={handleThemeGeneration} />
|
||||
</div>
|
||||
<AIChatForm handleThemeGeneration={handleThemeGeneration} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
export interface AIPillActionButtonProps extends ComponentProps<typeof Button> {}
|
||||
|
||||
export function AIPillActionButton({
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: AIPillActionButtonProps) {
|
||||
return (
|
||||
<div className={cn("group/pill relative active:scale-95", disabled && "pointer-events-none")}>
|
||||
<div className="from-primary to-background absolute inset-0 z-[-1] rounded-full bg-gradient-to-br opacity-0 transition-all duration-150 ease-in group-hover/pill:opacity-30" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 text-muted-foreground hover:text-foreground border-border/80! rounded-full border bg-transparent px-2 font-medium text-nowrap whitespace-nowrap backdrop-blur-md transition-all duration-150 ease-in select-none focus:outline-none",
|
||||
"group-hover/pill:inset-shadow-primary/50 h-7 inset-shadow-2xs inset-shadow-transparent [&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Logo from "@/assets/logo.svg";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { TooltipWrapper } from "@/components/tooltip-wrapper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAIChat } from "@/hooks/use-ai-chat";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { type ChatMessage as ChatMessageType } from "@/types/ai";
|
||||
import { ThemeStyles } from "@/types/theme";
|
||||
import { buildAIPromptRender } from "@/utils/ai-prompt";
|
||||
import { Goal, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ColorPreview from "../theme-preview/color-preview";
|
||||
import { ChatThemePreview } from "./chat-theme-preview";
|
||||
import { LoadingLogo } from "./loading-logo";
|
||||
|
||||
type ChatMessagesProps = {
|
||||
onRetry?: (messageIndex: number) => void;
|
||||
};
|
||||
|
||||
export function ChatMessages({ onRetry }: ChatMessagesProps) {
|
||||
const [isScrollTop, setIsScrollTop] = useState(true);
|
||||
const { messages } = useAIChat();
|
||||
const { loading: isAIGenerating } = useAIThemeGeneration();
|
||||
const previousMessages = useRef<ChatMessageType[]>(messages);
|
||||
|
||||
const messagesStartRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
// When switching tabs, messages do not change, so we don't need to animate the scroll
|
||||
const didMessagesChange = previousMessages.current.length !== messages.length;
|
||||
messagesEndRef.current.scrollIntoView({ behavior: didMessagesChange ? "smooth" : "auto" });
|
||||
|
||||
// Update the previous messages ref
|
||||
previousMessages.current = messages;
|
||||
}
|
||||
}, [messages, isAIGenerating]);
|
||||
|
||||
// Toggle top fade out effect when scrolling
|
||||
useEffect(() => {
|
||||
const startMarker = messagesStartRef.current;
|
||||
|
||||
if (!startMarker) return;
|
||||
|
||||
const observerOptions = {
|
||||
root: null, // Use viewport as root for more reliable detection
|
||||
threshold: 0,
|
||||
};
|
||||
|
||||
const startMessagesObserver = new IntersectionObserver(([entry]) => {
|
||||
setIsScrollTop(entry.isIntersecting);
|
||||
}, observerOptions);
|
||||
|
||||
startMessagesObserver.observe(startMarker);
|
||||
|
||||
return () => startMessagesObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollArea className="relative isolate flex flex-1 flex-col">
|
||||
{/* Top fade out effect when scrolling */}
|
||||
<div
|
||||
className={cn(
|
||||
"via-background/50 from-background pointer-events-none absolute top-0 right-0 left-0 z-20 h-8 bg-gradient-to-b to-transparent opacity-100 transition-opacity ease-out",
|
||||
isScrollTop ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
/>
|
||||
<div className="relative size-full flex-1 px-6 pt-2 pb-8">
|
||||
<div ref={messagesStartRef} />
|
||||
<div className="flex flex-col gap-8 text-pretty wrap-anywhere">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
messageIndex={index}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Loading message when AI is generating */}
|
||||
{isAIGenerating && (
|
||||
<div className="flex gap-1.5 pb-8">
|
||||
<div className="relative flex size-6 items-center justify-center">
|
||||
<LoadingLogo />
|
||||
</div>
|
||||
|
||||
<p className="inline-flex animate-pulse gap-0.25 delay-150">
|
||||
<span className="text-sm">Generating</span>
|
||||
<span className="animate-bounce delay-100">.</span>
|
||||
<span className="animate-bounce delay-200">.</span>
|
||||
<span className="animate-bounce delay-300">.</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatMessageProps = {
|
||||
message: ChatMessageType;
|
||||
messageIndex?: number;
|
||||
onRetry?: (messageIndex: number) => void;
|
||||
};
|
||||
|
||||
export default function ChatMessage({ message, messageIndex, onRetry }: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isAssistant = message.role === "assistant";
|
||||
|
||||
const { themeState, setThemeState } = useEditorStore();
|
||||
const { loading: isAIGenerating } = useAIThemeGeneration();
|
||||
|
||||
const handleResetThemeToMessageCheckpoint = (themeStyles?: ThemeStyles) => {
|
||||
if (!themeStyles) return;
|
||||
|
||||
setThemeState({
|
||||
...themeState,
|
||||
styles: themeStyles,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
if (messageIndex !== undefined && onRetry) {
|
||||
onRetry(messageIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayContent = () => {
|
||||
if (isUser && message.promptData) {
|
||||
return buildAIPromptRender(message.promptData);
|
||||
}
|
||||
return message.content || "";
|
||||
};
|
||||
|
||||
const getCopyContent = () => {
|
||||
if (isUser && message.promptData) {
|
||||
return message.promptData.content;
|
||||
}
|
||||
return message.content || "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-4", isUser ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn("flex w-full max-w-[90%] items-start gap-1.5", isUser && "flex-row-reverse")}
|
||||
>
|
||||
{isAssistant && (
|
||||
<div className="border-border/50! bg-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border select-none">
|
||||
<Logo className="text-background size-full p-0.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn("group/message relative", isAssistant && "w-full")}>
|
||||
<p
|
||||
className={cn(
|
||||
"bg-red w-fit text-sm",
|
||||
isUser && "bg-muted/80 text-foreground/80 border-border/50! rounded-lg border p-4"
|
||||
)}
|
||||
>
|
||||
{getDisplayContent()}
|
||||
</p>
|
||||
|
||||
{isAssistant && message.themeStyles && (
|
||||
<div className="mt-2">
|
||||
<ChatThemePreview themeStyles={message.themeStyles} className="p-0">
|
||||
<ScrollArea className="h-48">
|
||||
<div className="p-2">
|
||||
<ColorPreview
|
||||
styles={message.themeStyles}
|
||||
currentMode={themeState.currentMode}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ChatThemePreview>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex gap-2 opacity-0 transition-opacity duration-300 ease-out group-hover/message:opacity-100",
|
||||
isUser ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{isUser && onRetry && messageIndex !== undefined && (
|
||||
<TooltipWrapper label="Retry this prompt" asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 [&>svg]:size-3.5"
|
||||
disabled={isAIGenerating}
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
|
||||
<CopyButton textToCopy={getCopyContent()} />
|
||||
|
||||
{isAssistant && message.themeStyles && (
|
||||
<TooltipWrapper label="Reset to this checkpoint" asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 [&>svg]:size-3.5"
|
||||
disabled={isAIGenerating}
|
||||
onClick={() => handleResetThemeToMessageCheckpoint(message.themeStyles)}
|
||||
>
|
||||
<Goal />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeStyles } from "@/types/theme";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ComponentProps, useState } from "react";
|
||||
|
||||
interface ChatThemePreviewProps extends ComponentProps<"div"> {
|
||||
themeStyles: ThemeStyles;
|
||||
}
|
||||
|
||||
export function ChatThemePreview({
|
||||
themeStyles,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ChatThemePreviewProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { theme: mode } = useTheme();
|
||||
|
||||
return (
|
||||
<div className={cn("border-border/75! max-w-[550px] overflow-hidden rounded-lg border")}>
|
||||
<div
|
||||
className={cn(
|
||||
"group/control bg-background hover:bg-muted/40 flex h-10 w-full shrink-0 cursor-pointer items-center justify-between gap-4 p-2.5 transition-colors duration-300 ease-in-out",
|
||||
isExpanded ? "bg-muted/40" : "border-transparent"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"group-hover/control:text-foreground text-foreground/75 text-sm font-medium",
|
||||
isExpanded && "text-foreground"
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</h3>
|
||||
<div className="flex gap-0.5">
|
||||
<div
|
||||
className="border-muted bg-primary h-3 w-3 rounded-sm border"
|
||||
style={{ backgroundColor: themeStyles[mode].primary }}
|
||||
/>
|
||||
<div
|
||||
className="border-muted bg-secondary h-3 w-3 rounded-sm border"
|
||||
style={{ backgroundColor: themeStyles[mode].secondary }}
|
||||
/>
|
||||
<div
|
||||
className="border-muted bg-background h-3 w-3 rounded-sm border"
|
||||
style={{ backgroundColor: themeStyles[mode].background }}
|
||||
/>
|
||||
<div
|
||||
className="border-muted bg-muted h-3 w-3 rounded-sm border"
|
||||
style={{ backgroundColor: themeStyles[mode].muted }}
|
||||
/>
|
||||
<div
|
||||
className="border-muted bg-accent h-3 w-3 rounded-sm border"
|
||||
style={{ backgroundColor: themeStyles[mode].accent }}
|
||||
/>
|
||||
<div
|
||||
className="border-muted bg-border h-3 w-3 rounded-sm border"
|
||||
style={{ backgroundColor: themeStyles[mode].border }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground/75 group-hover/control:text-foreground ml-auto transition-colors"
|
||||
aria-label={isExpanded ? "Collapse section" : "Expand section"}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-300 ease-in-out",
|
||||
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="bg-background overflow-hidden">
|
||||
<div className={cn("space-y-2 p-2.5", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { PROMPTS } from "@/utils/prompts";
|
||||
import { createCurrentThemePrompt } from "@/utils/ai-prompt";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AIPillActionButton } from "./ai-pill-action-button";
|
||||
|
||||
export function ClosableSuggestedPillActions({
|
||||
handleThemeGeneration,
|
||||
}: {
|
||||
handleThemeGeneration: (promptData: AIPromptData | null) => void;
|
||||
}) {
|
||||
const [hasClosedSuggestions, setHasClosedSuggestions] = useState(false);
|
||||
const { loading: aiIsGenerating } = useAIThemeGeneration();
|
||||
|
||||
const handleSetPrompt = async (prompt: string) => {
|
||||
const promptData = createCurrentThemePrompt({ prompt });
|
||||
handleThemeGeneration(promptData);
|
||||
};
|
||||
|
||||
if (hasClosedSuggestions) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
{/* Fade out effect when scrolling */}
|
||||
<div className="via-background/50 from-background pointer-events-none absolute -top-8 right-0 left-0 z-20 h-8 bg-gradient-to-t to-transparent opacity-100 transition-opacity ease-out" />
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<h3 className="text-muted-foreground text-xs">Suggestions</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 [&>svg]:size-3"
|
||||
onClick={() => setHasClosedSuggestions(true)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<HorizontalScrollArea className="pt-1 pb-2">
|
||||
{Object.entries(PROMPTS).map(([key, { label, prompt }]) => (
|
||||
<AIPillActionButton
|
||||
key={key}
|
||||
onClick={() => handleSetPrompt(prompt)}
|
||||
disabled={aiIsGenerating}
|
||||
>
|
||||
<Sparkles /> {label}
|
||||
</AIPillActionButton>
|
||||
))}
|
||||
</HorizontalScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@ export function LoadingLogo() {
|
||||
<>
|
||||
<div className="bg-primary/90 absolute inset-0 -z-1 m-auto size-[65%] animate-ping rounded-full" />
|
||||
<div className="bg-primary/30 absolute inset-0 -z-1 m-auto size-[85%] animate-ping rounded-full delay-100" />
|
||||
<div className="bg-background relative isolate mx-auto size-full rounded-full p-1">
|
||||
<div className="bg-background relative isolate mx-auto size-full rounded-full">
|
||||
<div className="absolute top-0 left-0 size-[30%]">
|
||||
<Sparkle className="size-full animate-pulse fill-current" />
|
||||
</div>
|
||||
<Logo className="size-full animate-pulse delay-150" />
|
||||
<Logo className="size-full animate-pulse p-0.5 delay-150" />
|
||||
<div className="absolute right-0 bottom-0 size-[20%]">
|
||||
<Sparkle className="size-full animate-pulse fill-current delay-300" />
|
||||
</div>
|
||||
@@ -0,0 +1,210 @@
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { createCurrentThemePrompt } from "@/utils/ai-prompt";
|
||||
import { PROMPTS } from "@/utils/prompts";
|
||||
import { Blend, PaintRoller, WandSparkles } from "lucide-react";
|
||||
import { ComponentProps, Fragment } from "react";
|
||||
import TabsTriggerPill from "../theme-preview/tabs-trigger-pill";
|
||||
|
||||
interface RemixPrompt {
|
||||
displayContent: string;
|
||||
prompt: string;
|
||||
basePreset: string;
|
||||
}
|
||||
|
||||
interface Prompt {
|
||||
displayContent: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const CREATE_PROMPTS: Prompt[] = [
|
||||
{
|
||||
displayContent: "JavaScript/TypeScript Advent of Code playground",
|
||||
prompt:
|
||||
"Create a retro JavaScript Advent of Code theme. Use a grayish background with JavaScript yellow and TypeScript blue as primary/secondary colors. Change all fonts to monospace. Make borders sharp.",
|
||||
},
|
||||
{
|
||||
displayContent: "Retro Terminal UI, green phosphor glow",
|
||||
prompt:
|
||||
"Create a retro terminal theme with black background (in dark mode), phosphorescent green primary and text, and all fonts monospace.",
|
||||
},
|
||||
{
|
||||
displayContent: "Monochrome Manga-inspired theme",
|
||||
prompt:
|
||||
"Create a Manga-inspired theme. Monochromatic palette (black, off-white, grays), square corners, small contrast solid offset shadows, and high-contrast borders (black on light, off-white on dark). Use a playful font, like Architects daughter.",
|
||||
},
|
||||
{
|
||||
displayContent: "I want a minimal Ghibli Studio vibe",
|
||||
prompt:
|
||||
"Generate a theme inspired by Studio Ghibli — soft pastels, natural greens, organic colors, and hand-drawn charm.",
|
||||
},
|
||||
];
|
||||
|
||||
const REMIX_PROMPTS: RemixPrompt[] = [
|
||||
{
|
||||
displayContent: "Make @Twitter but in a slick purple",
|
||||
prompt: "Make @Twitter but in a slick purple",
|
||||
basePreset: "twitter",
|
||||
},
|
||||
{
|
||||
displayContent: "What if @Supabase was vibrant blue?",
|
||||
prompt: "Make @Supabase but in vibrant blue",
|
||||
basePreset: "supabase",
|
||||
},
|
||||
{
|
||||
displayContent: "I want @Doom 64 with muted colors",
|
||||
prompt: "I want @Doom 64 with alternate colors",
|
||||
basePreset: "doom-64",
|
||||
},
|
||||
];
|
||||
|
||||
const VARIANT_PROMPTS: Prompt[] = [
|
||||
{
|
||||
displayContent: "Make my @Current Theme minimalistic",
|
||||
prompt: PROMPTS.minimalStyle.prompt,
|
||||
},
|
||||
{
|
||||
displayContent: "Flatten the colors of my @Current Theme",
|
||||
prompt: PROMPTS.flatDesign.prompt,
|
||||
},
|
||||
{
|
||||
displayContent: "Create a brutalist variant of my @Current Theme",
|
||||
prompt: PROMPTS.brutalist.prompt,
|
||||
},
|
||||
];
|
||||
|
||||
// This can be moved to the ai-prompt utils
|
||||
const createPromptDataFromPreset = (prompt: string, presetName: string): AIPromptData => {
|
||||
const preset = useThemePresetStore.getState().getPreset(presetName);
|
||||
|
||||
if (!preset) {
|
||||
throw new Error(`Preset "${presetName}" not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: prompt,
|
||||
mentions: [
|
||||
{
|
||||
id: presetName,
|
||||
label: preset.label ?? presetName,
|
||||
themeData: {
|
||||
light: preset.styles.light || {},
|
||||
dark: preset.styles.dark || {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export function NoMessagesPlaceholder({
|
||||
handleThemeGeneration,
|
||||
}: {
|
||||
handleThemeGeneration: (promptData: AIPromptData | null) => void;
|
||||
}) {
|
||||
const { data: session } = authClient.useSession();
|
||||
const { loading: isGenerating } = useAIThemeGeneration();
|
||||
|
||||
const userName = session?.user.name?.split(" ")[0];
|
||||
const heading = `What can I help you theme${userName ? `, ${userName}` : ""}?`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-lg flex-col gap-4">
|
||||
<h2 className="text-[clamp(22px,6cqw,32px)] font-semibold tracking-tighter text-pretty">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
<Tabs defaultValue="create-prompts">
|
||||
<HorizontalScrollArea className="mb-1">
|
||||
<TabsList className="m-0 bg-transparent p-0">
|
||||
<TabsTriggerPill value="create-prompts" className="flex items-center gap-1">
|
||||
<PaintRoller className="size-3.5" aria-hidden="true" />
|
||||
Create
|
||||
</TabsTriggerPill>
|
||||
<TabsTriggerPill value="variant-prompts" className="flex items-center gap-1">
|
||||
<Blend className="size-3.5" aria-hidden="true" />
|
||||
Remix
|
||||
</TabsTriggerPill>
|
||||
<TabsTriggerPill value="tweak-prompts" className="flex items-center gap-1">
|
||||
<WandSparkles className="size-3.5" aria-hidden="true" />
|
||||
Tweak
|
||||
</TabsTriggerPill>
|
||||
</TabsList>
|
||||
</HorizontalScrollArea>
|
||||
|
||||
<TabsContent value="create-prompts">
|
||||
{CREATE_PROMPTS.map((prompt, index) => (
|
||||
<Fragment key={`create-${index}`}>
|
||||
<PromptButton
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
handleThemeGeneration({
|
||||
content: prompt.prompt,
|
||||
mentions: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
{prompt.displayContent}
|
||||
</PromptButton>
|
||||
{index < CREATE_PROMPTS.length - 1 && <Separator className="bg-border/50" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="variant-prompts">
|
||||
{REMIX_PROMPTS.map((prompt, index) => (
|
||||
<Fragment key={`variant-${index}`}>
|
||||
<PromptButton
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
handleThemeGeneration(
|
||||
createPromptDataFromPreset(prompt.prompt, prompt.basePreset)
|
||||
)
|
||||
}
|
||||
>
|
||||
{prompt.displayContent}
|
||||
</PromptButton>
|
||||
{index < REMIX_PROMPTS.length - 1 && <Separator className="bg-border/50" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tweak-prompts">
|
||||
{VARIANT_PROMPTS.map((prompt, index) => (
|
||||
<Fragment key={`variant-${index}`}>
|
||||
<PromptButton
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
handleThemeGeneration(createCurrentThemePrompt({ prompt: prompt.prompt }))
|
||||
}
|
||||
>
|
||||
{prompt.displayContent}
|
||||
</PromptButton>
|
||||
{index < VARIANT_PROMPTS.length - 1 && <Separator className="bg-border/50" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromptButtonProps extends ComponentProps<typeof Button> {}
|
||||
|
||||
function PromptButton({ className, children, ...props }: PromptButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("text-muted-foreground w-full justify-start font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -189,13 +189,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="px-2 w-full justify-start">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start px-2">
|
||||
<Contrast className="h-4 w-4" />
|
||||
<span className="text-sm hidden md:block">Contrast</span>
|
||||
Contrast
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] p-0 py-6 overflow-hidden rounded-lg border shadow-lg gap-6 flex flex-col">
|
||||
<div className="flex justify-between items-end px-6 sm:flex-row flex-col gap-4">
|
||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col gap-6 overflow-hidden rounded-lg border p-0 py-6 shadow-lg">
|
||||
<div className="flex flex-col items-end justify-between gap-4 px-6 sm:flex-row">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contrast Checker</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -204,14 +204,14 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
href="https://www.w3.org/TR/WCAG21/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline hover:text-primary/80 transition-colors"
|
||||
className="text-primary hover:text-primary/80 underline transition-colors"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="items-center gap-2 hidden md:flex">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -242,13 +242,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
size="sm"
|
||||
onClick={() => setFilter("issues")}
|
||||
>
|
||||
<AlertTriangle className={cn("h-3 w-3 mr-1")} />
|
||||
<AlertTriangle className={cn("mr-1 h-3 w-3")} />
|
||||
Issues ({totalIssues})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 relative flex flex-col">
|
||||
<ScrollArea className="relative flex flex-1 flex-col">
|
||||
<div className="space-y-6 px-6">
|
||||
{groupedPairs.map((group) => (
|
||||
<div key={group.category} className="space-y-4">
|
||||
@@ -256,35 +256,29 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
<h2 className="text-md font-semibold">{group.label}</h2>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{group.pairs.map((pair) => {
|
||||
const result = getContrastResult(pair.id);
|
||||
const isValid =
|
||||
result?.contrastRatio !== undefined &&
|
||||
result?.contrastRatio >= MIN_CONTRAST_RATIO;
|
||||
const contrastRatio =
|
||||
result?.contrastRatio?.toFixed(2) ?? "N/A";
|
||||
const contrastRatio = result?.contrastRatio?.toFixed(2) ?? "N/A";
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={pair.id}
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
!isValid && " border-dashed"
|
||||
)}
|
||||
className={cn("transition-all duration-200", !isValid && "border-dashed")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-medium flex items-center",
|
||||
"flex items-center font-medium",
|
||||
!isValid && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{pair.label}
|
||||
{!isValid && (
|
||||
<AlertTriangle className="size-3.5 ml-1" />
|
||||
)}
|
||||
{!isValid && <AlertTriangle className="ml-1 size-3.5" />}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={isValid ? "default" : "destructive"}
|
||||
@@ -309,21 +303,18 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col items-center gap-3 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 flex-col items-center gap-3">
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor:
|
||||
pair.background ?? "#000000",
|
||||
backgroundColor: pair.background ?? "#000000",
|
||||
}}
|
||||
className="h-12 w-12 rounded-md border shadow-sm flex-shrink-0"
|
||||
className="h-12 w-12 flex-shrink-0 rounded-md border shadow-sm"
|
||||
></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">
|
||||
Background
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="text-xs font-medium">Background</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{pair.background}
|
||||
</span>
|
||||
</div>
|
||||
@@ -332,16 +323,13 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor:
|
||||
pair.foreground ?? "#ffffff",
|
||||
backgroundColor: pair.foreground ?? "#ffffff",
|
||||
}}
|
||||
className="h-12 w-12 rounded-md border shadow-sm flex-shrink-0"
|
||||
className="h-12 w-12 flex-shrink-0 rounded-md border shadow-sm"
|
||||
></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">
|
||||
Foreground
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="text-xs font-medium">Foreground</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{pair.foreground}
|
||||
</span>
|
||||
</div>
|
||||
@@ -350,16 +338,15 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor:
|
||||
pair.background ?? "transparent",
|
||||
backgroundColor: pair.background ?? "transparent",
|
||||
}}
|
||||
className="flex-1 h-full min-h-[120px] rounded-lg border shadow-sm flex items-center justify-center overflow-hidden"
|
||||
className="flex h-full min-h-[120px] flex-1 items-center justify-center overflow-hidden rounded-lg border shadow-sm"
|
||||
>
|
||||
{pair.foreground && pair.background ? (
|
||||
<div className="text-center p-4">
|
||||
<div className="p-4 text-center">
|
||||
<p
|
||||
style={{ color: pair.foreground }}
|
||||
className="text-4xl font-bold tracking-wider mb-2"
|
||||
className="mb-2 text-4xl font-bold tracking-wider"
|
||||
>
|
||||
Aa
|
||||
</p>
|
||||
@@ -371,9 +358,7 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">Preview</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useEditor, EditorContent, JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { suggestion } from "@/components/editor/mention-suggestion";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { AIPromptData, MentionReference } from "@/types/ai";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { suggestion } from "@/components/editor/mention-suggestion"; // We'll create this next
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { EditorContent, JSONContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
interface CustomTextareaProps {
|
||||
onContentChange: (textContent: string, jsonContent: JSONContent) => void;
|
||||
onContentChange: (promptData: AIPromptData) => void;
|
||||
onGenerate?: () => void;
|
||||
characterLimit?: number;
|
||||
}
|
||||
|
||||
const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGenerate }) => {
|
||||
const convertJSONContentToPromptData = (jsonContent: JSONContent): AIPromptData => {
|
||||
const content =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
jsonContent.content?.[0]?.content?.reduce((text: string, node: any) => {
|
||||
if (node.type === "text") return text + node.text;
|
||||
if (node.type === "mention") return text + `@${node.attrs?.label}`;
|
||||
return text;
|
||||
}, "") || "";
|
||||
|
||||
const mentions: MentionReference[] =
|
||||
jsonContent.content?.[0]?.content
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
?.filter((node: any) => node.type === "mention")
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
?.map((mention: any) => {
|
||||
const id = mention.attrs?.id;
|
||||
const label = mention.attrs?.label;
|
||||
|
||||
let themeData;
|
||||
if (id === "editor:current-changes") {
|
||||
themeData = useEditorStore.getState().themeState.styles;
|
||||
} else {
|
||||
const preset = useThemePresetStore.getState().getPreset(id);
|
||||
themeData = preset?.styles || { light: {}, dark: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
themeData,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return {
|
||||
content,
|
||||
mentions,
|
||||
};
|
||||
};
|
||||
|
||||
const CustomTextarea: React.FC<CustomTextareaProps> = ({
|
||||
onContentChange,
|
||||
onGenerate,
|
||||
characterLimit,
|
||||
}) => {
|
||||
const { loading: aiGenerateLoading } = useAIThemeGeneration();
|
||||
const { toast } = useToast();
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Mention.configure({
|
||||
@@ -30,32 +82,31 @@ const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGene
|
||||
emptyEditorClass:
|
||||
"cursor-text before:content-[attr(data-placeholder)] before:absolute before:top-2 before:left-3 before:text-mauve-11 before:opacity-50 before-pointer-events-none",
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: characterLimit,
|
||||
}),
|
||||
],
|
||||
autofocus: !aiGenerateLoading,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"min-h-[60px] max-h-[150px] wrap-anywhere text-foreground/90 scrollbar-thin overflow-y-auto w-full rounded-md bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50",
|
||||
"min-h-[60px] max-h-[150px] wrap-anywhere text-foreground/90 scrollbar-thin overflow-y-auto w-full rounded-md bg-background px-3 py-2 pb-6 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50 max-sm:text-[16px]!",
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
if (event.key === "Enter" && !event.shiftKey && !aiGenerateLoading) {
|
||||
const { state } = view;
|
||||
const mentionPluginKey = Mention.options.suggestion.pluginKey;
|
||||
|
||||
// Ensure the plugin key exists before trying to get state
|
||||
if (!mentionPluginKey) {
|
||||
console.error("Mention plugin key not found.");
|
||||
// Fallback: allow default Enter behavior if key is missing
|
||||
return false;
|
||||
}
|
||||
|
||||
const mentionState = mentionPluginKey.getState(state);
|
||||
|
||||
if (mentionState?.active) {
|
||||
// Mention list is active, let the mention extension handle Enter.
|
||||
return false;
|
||||
} else {
|
||||
// Mention list is not active, submit the prompt.
|
||||
event.preventDefault();
|
||||
onGenerate?.();
|
||||
return true;
|
||||
@@ -63,15 +114,38 @@ const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGene
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (!characterLimit) return false;
|
||||
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return false;
|
||||
|
||||
const pastedText = clipboardData.getData("text/plain");
|
||||
const currentCharacterCount = editor?.storage.characterCount.characters() || 0;
|
||||
const totalCharacters = currentCharacterCount + pastedText.length;
|
||||
|
||||
if (totalCharacters > characterLimit) {
|
||||
event.preventDefault();
|
||||
toast({
|
||||
title: "Text too long",
|
||||
description: `The pasted content would exceed the ${characterLimit} character limit.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onContentChange(editor.getText(), editor.getJSON());
|
||||
const jsonContent = editor.getJSON();
|
||||
const promptData = convertJSONContentToPromptData(jsonContent);
|
||||
onContentChange(promptData);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(!aiGenerateLoading);
|
||||
editor.commands.blur();
|
||||
}
|
||||
}, [aiGenerateLoading, editor]);
|
||||
@@ -80,7 +154,27 @@ const CustomTextarea: React.FC<CustomTextareaProps> = ({ onContentChange, onGene
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
const characterCount = editor.storage.characterCount.characters();
|
||||
const isLimitExceeded = characterLimit && characterCount > characterLimit;
|
||||
const shouldShowCount = characterLimit && characterCount >= characterLimit * 0.9;
|
||||
|
||||
return (
|
||||
<div className="relative isolate">
|
||||
<EditorContent editor={editor} />
|
||||
{shouldShowCount && (
|
||||
<div className="pointer-events-none absolute right-3 bottom-2 z-10 flex text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"bg-background/10 rounded-full px-0.5 backdrop-blur-xs",
|
||||
isLimitExceeded ? "text-destructive" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{characterCount} / {characterLimit}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTextarea;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import React, { useEffect, use } from "react";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EditorConfig } from "@/types/editor";
|
||||
import { Theme, ThemeStyles } from "@/types/theme";
|
||||
import { Sliders } from "lucide-react";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { ActionBar } from "./action-bar/action-bar";
|
||||
import ThemeControlPanel from "./theme-control-panel";
|
||||
import ThemePreviewPanel from "./theme-preview-panel";
|
||||
|
||||
interface EditorProps {
|
||||
config: EditorConfig;
|
||||
themePromise: Promise<Theme | null>;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,9 @@ const isThemeStyles = (styles: unknown): styles is ThemeStyles => {
|
||||
);
|
||||
};
|
||||
|
||||
const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
const Editor: React.FC<EditorProps> = ({ themePromise }) => {
|
||||
const themeState = useEditorStore((state) => state.themeState);
|
||||
const setThemeState = useEditorStore((state) => state.setThemeState);
|
||||
const Controls = config.controls;
|
||||
const Preview = config.preview;
|
||||
|
||||
const initialTheme = themePromise ? use(themePromise) : null;
|
||||
|
||||
@@ -62,13 +60,18 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
const styles = themeState.styles;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="relative isolate flex flex-1 overflow-hidden">
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden h-full md:block">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
<ResizablePanel defaultSize={30} minSize={20} maxSize={40}>
|
||||
<div className="flex h-full flex-col">
|
||||
<Controls
|
||||
<div className="hidden size-full md:block">
|
||||
<ResizablePanelGroup direction="horizontal" className="isolate">
|
||||
<ResizablePanel
|
||||
defaultSize={30}
|
||||
minSize={20}
|
||||
maxSize={40}
|
||||
className="z-1 min-w-[max(20%,22rem)]"
|
||||
>
|
||||
<div className="relative isolate flex h-full flex-1 flex-col">
|
||||
<ThemeControlPanel
|
||||
styles={styles}
|
||||
onChange={handleStyleChange}
|
||||
currentMode={themeState.currentMode}
|
||||
@@ -77,19 +80,18 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={70} minSize={20}>
|
||||
<ResizablePanel defaultSize={70}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ActionBar />
|
||||
<Preview styles={styles} currentMode={themeState.currentMode} />
|
||||
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className="h-full md:hidden">
|
||||
<div className="h-full w-full flex-1 overflow-hidden md:hidden">
|
||||
<Tabs defaultValue="controls" className="h-full">
|
||||
<TabsList className="w-full rounded-none">
|
||||
<TabsTrigger value="controls" className="flex-1">
|
||||
@@ -102,7 +104,7 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
</TabsList>
|
||||
<TabsContent value="controls" className="mt-0 h-[calc(100%-2.5rem)]">
|
||||
<div className="flex h-full flex-col">
|
||||
<Controls
|
||||
<ThemeControlPanel
|
||||
styles={styles}
|
||||
onChange={handleStyleChange}
|
||||
currentMode={themeState.currentMode}
|
||||
@@ -113,7 +115,7 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
<TabsContent value="preview" className="mt-0 h-[calc(100%-2.5rem)]">
|
||||
<div className="flex h-full flex-col">
|
||||
<ActionBar />
|
||||
<Preview styles={styles} currentMode={themeState.currentMode} />
|
||||
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useState, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define the structure of the theme item object
|
||||
@@ -25,97 +20,112 @@ export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
(props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
export const MentionList = forwardRef<MentionListRef, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Function to select item (adapted from reference)
|
||||
const selectItem = (index: number) => {
|
||||
console.log("selectItem", index);
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
// Pass the whole item object to the command function
|
||||
props.command(item);
|
||||
// Function to select item (adapted from reference)
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
// Pass the whole item object to the command function
|
||||
props.command(item);
|
||||
}
|
||||
};
|
||||
|
||||
// Arrow key handlers using modulo (adapted from reference)
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((prevIndex) => (prevIndex + props.items.length - 1) % props.items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
// Auto-scroll effect when selectedIndex changes
|
||||
useEffect(() => {
|
||||
if (selectedItemRef.current && containerRef.current) {
|
||||
selectedItemRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
// Use modulo handlers
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Arrow key handlers using modulo (adapted from reference)
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(
|
||||
(prevIndex) => (prevIndex + props.items.length - 1) % props.items.length
|
||||
);
|
||||
};
|
||||
if (event.key === "ArrowDown") {
|
||||
// Use modulo handlers
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
|
||||
};
|
||||
if (event.key === "Enter") {
|
||||
// Use enter handler
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
if (event.key === "Tab") {
|
||||
// Use tab handler
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
// Use modulo handlers
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
// Use modulo handlers
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
// Use enter handler
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
)}
|
||||
style={{ position: "absolute", pointerEvents: "auto" }}
|
||||
>
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
// Use Tailwind classes mimicking shadcn/ui DropdownMenuItem with cn utility
|
||||
className={cn(
|
||||
"relative flex w-full items-center rounded-sm p-1.5 text-xs outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
index === selectedIndex && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
key={item.id} // Use item.id as the key
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(index);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"scrollbar-thin max-h-[180px] overflow-y-auto"
|
||||
)}
|
||||
>
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
ref={index === selectedIndex ? selectedItemRef : null}
|
||||
// Use Tailwind classes mimicking shadcn/ui DropdownMenuItem with cn utility
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm p-1.5 text-xs text-muted-foreground"
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex w-full items-center rounded-sm p-1.5 text-xs transition-colors outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
index === selectedIndex && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
key={item.id} // Use item.id as the key
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(index);
|
||||
}}
|
||||
>
|
||||
No result
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
{item.label}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground relative flex cursor-default items-center rounded-sm p-1.5 text-xs select-none"
|
||||
)}
|
||||
>
|
||||
No result
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MentionList.displayName = "MentionList";
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Sparkles } from "lucide-react";
|
||||
import React, { use } from "react";
|
||||
import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme";
|
||||
import ControlSection from "./control-section";
|
||||
import ColorPicker from "./color-picker";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import ThemePresetSelect from "./theme-preset-select";
|
||||
import {
|
||||
getAppliedThemeFont,
|
||||
monoFonts,
|
||||
sansSerifFonts,
|
||||
serifFonts,
|
||||
} from "../../utils/theme-fonts";
|
||||
import { useEditorStore } from "../../store/editor-store";
|
||||
import { Label } from "../ui/label";
|
||||
import { SliderWithInput } from "./slider-with-input";
|
||||
import { Tabs, TabsList, TabsContent } from "../ui/tabs";
|
||||
import ThemeFontSelect from "./theme-font-select";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
|
||||
import {
|
||||
COMMON_STYLES,
|
||||
DEFAULT_FONT_MONO,
|
||||
DEFAULT_FONT_SANS,
|
||||
DEFAULT_FONT_SERIF,
|
||||
defaultThemeState,
|
||||
} from "../../config/theme";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import ShadowControl from "./shadow-control";
|
||||
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
|
||||
import ThemeEditActions from "./theme-edit-actions";
|
||||
} from "@/config/theme";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
import { useControlsTabFromUrl, type ControlTab } from "@/hooks/use-controls-tab-from-url";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme";
|
||||
import { getAppliedThemeFont, monoFonts, sansSerifFonts, serifFonts } from "@/utils/theme-fonts";
|
||||
import { HorizontalScrollArea } from "../horizontal-scroll-area";
|
||||
import { AIInterface } from "./ai/ai-interface";
|
||||
import ColorPicker from "./color-picker";
|
||||
import ControlSection from "./control-section";
|
||||
import HslAdjustmentControls from "./hsl-adjustment-controls";
|
||||
import ShadowControl from "./shadow-control";
|
||||
import { SliderWithInput } from "./slider-with-input";
|
||||
import ThemeEditActions from "./theme-edit-actions";
|
||||
import ThemeFontSelect from "./theme-font-select";
|
||||
import ThemePresetSelect from "./theme-preset-select";
|
||||
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
|
||||
|
||||
const ThemeControlPanel = ({
|
||||
styles,
|
||||
@@ -38,6 +38,8 @@ const ThemeControlPanel = ({
|
||||
themePromise,
|
||||
}: ThemeEditorControlsProps) => {
|
||||
const { themeState } = useEditorStore();
|
||||
const { tab, handleSetTab } = useControlsTabFromUrl();
|
||||
const { loading: aiGenerationLoading } = useAIThemeGeneration();
|
||||
|
||||
const currentStyles = React.useMemo(
|
||||
() => ({
|
||||
@@ -83,23 +85,36 @@ const ThemeControlPanel = ({
|
||||
<>
|
||||
<div className="border-b">
|
||||
{!theme ? (
|
||||
<ThemePresetSelect className="h-14 rounded-none" />
|
||||
<ThemePresetSelect className="h-14 rounded-none" disabled={aiGenerationLoading} />
|
||||
) : (
|
||||
<ThemeEditActions theme={theme} />
|
||||
<ThemeEditActions theme={theme} disabled={aiGenerationLoading} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-4">
|
||||
<Tabs defaultValue="colors" className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="mt-2 px-4">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => handleSetTab(v as ControlTab)}
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<HorizontalScrollArea className="mt-2 mb-1 px-4">
|
||||
<TabsList className="bg-background text-muted-foreground inline-flex w-fit items-center justify-center rounded-full px-0">
|
||||
<TabsTriggerPill value="colors">Colors</TabsTriggerPill>
|
||||
<TabsTriggerPill value="typography">Typography</TabsTriggerPill>
|
||||
<TabsTriggerPill value="other">Other</TabsTriggerPill>
|
||||
<TabsTriggerPill
|
||||
value="ai"
|
||||
className="data-[state=active]:[--effect:var(--secondary-foreground)] data-[state=active]:[--foreground:var(--muted-foreground)] data-[state=active]:[--muted-foreground:var(--effect)]"
|
||||
>
|
||||
<Sparkles className="mr-1 size-3.5 text-current" />
|
||||
<span className="animate-text via-foreground from-muted-foreground to-muted-foreground flex items-center gap-1 bg-gradient-to-r from-50% via-60% to-100% bg-[200%_auto] bg-clip-text text-sm text-transparent">
|
||||
Generate
|
||||
</span>
|
||||
</TabsTriggerPill>
|
||||
</TabsList>
|
||||
</div>
|
||||
</HorizontalScrollArea>
|
||||
|
||||
<ScrollArea className="h-full flex-1 p-4 pt-0">
|
||||
<TabsContent value="colors">
|
||||
<TabsContent value="colors" className="mt-1 size-full overflow-hidden">
|
||||
<ScrollArea className="h-full px-4">
|
||||
<ControlSection title="Primary Colors" id="primary-colors" expanded>
|
||||
<ColorPicker
|
||||
color={currentStyles.primary}
|
||||
@@ -292,10 +307,12 @@ const ThemeControlPanel = ({
|
||||
label="Sidebar Ring"
|
||||
/>
|
||||
</ControlSection>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="typography" className="flex flex-col gap-4">
|
||||
<div className="bg-muted/50 mb-2 flex items-start gap-2.5 rounded-md border p-3">
|
||||
<TabsContent value="typography" className="mt-1 size-full overflow-hidden">
|
||||
<ScrollArea className="h-full px-4">
|
||||
<div className="bg-muted/50 mb-4 flex items-start gap-2.5 rounded-md border p-3">
|
||||
<AlertCircle className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>
|
||||
@@ -365,9 +382,11 @@ const ThemeControlPanel = ({
|
||||
label="Letter Spacing"
|
||||
/>
|
||||
</ControlSection>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="other">
|
||||
<TabsContent value="other" className="mt-1 size-full overflow-hidden">
|
||||
<ScrollArea className="h-full px-4">
|
||||
<ControlSection title="HSL Adjustments" expanded>
|
||||
<HslAdjustmentControls />
|
||||
</ControlSection>
|
||||
@@ -415,8 +434,12 @@ const ThemeControlPanel = ({
|
||||
}}
|
||||
/>
|
||||
</ControlSection>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai" className="mt-1 size-full overflow-hidden">
|
||||
<AIInterface />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { X, Check } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useThemeActions } from "@/hooks/use-theme-actions";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { Theme } from "@/types/theme";
|
||||
import { ThemeSaveDialog } from "./theme-save-dialog";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { ThemeSaveDialog } from "./theme-save-dialog";
|
||||
|
||||
interface ThemeEditActionsProps {
|
||||
theme: Theme;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
|
||||
const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme, disabled = false }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateTheme } = useThemeActions();
|
||||
const { themeState, applyThemePreset } = useEditorStore();
|
||||
const [isNameDialogOpen, setIsNameDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const mainEditorUrl = `/editor/theme?${searchParams}`;
|
||||
|
||||
const handleThemeEditCancel = () => {
|
||||
router.push("/editor/theme");
|
||||
// Keep the current search params for tab persistence
|
||||
router.push(mainEditorUrl);
|
||||
applyThemePreset(themeState?.preset || "default");
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
|
||||
|
||||
if (result) {
|
||||
setIsNameDialogOpen(false);
|
||||
router.push("/editor/theme");
|
||||
router.push(mainEditorUrl);
|
||||
applyThemePreset(result?.id || themeState?.preset || "default");
|
||||
} else {
|
||||
console.error("Failed to update theme");
|
||||
@@ -74,20 +74,16 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center bg-card/80 text-card-foreground">
|
||||
<div className="flex flex-1 items-center gap-2 min-h-14 px-4">
|
||||
<div className="flex items-center gap-2 animate-pulse">
|
||||
<div className="bg-card/80 text-card-foreground flex items-center">
|
||||
<div className="flex min-h-14 flex-1 items-center gap-2 px-4">
|
||||
<div className="flex animate-pulse items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-sm font-medium text-card-foreground/60">
|
||||
Editing
|
||||
</span>
|
||||
<span className="text-card-foreground/60 text-sm font-medium">Editing</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold px-2 truncate max-w-56">
|
||||
{theme.name}
|
||||
</span>
|
||||
<span className="max-w-56 truncate px-2 text-sm font-semibold">{theme.name}</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-8 bg-border" />
|
||||
<Separator orientation="vertical" className="bg-border h-8" />
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -97,6 +93,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
|
||||
size="icon"
|
||||
className="size-14 shrink-0 rounded-none"
|
||||
onClick={handleThemeEditCancel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -105,7 +102,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Separator orientation="vertical" className="h-8 bg-border" />
|
||||
<Separator orientation="vertical" className="bg-border h-8" />
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -115,6 +112,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme }) => {
|
||||
size="icon"
|
||||
className="size-14 shrink-0 rounded-none"
|
||||
onClick={handleThemeEditSave}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -433,6 +433,7 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
|
||||
filteredPresets={filteredPresets}
|
||||
currentPresetName={currentPresetName || "default"}
|
||||
className={className}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeEditorPreviewProps } from "@/types/theme";
|
||||
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
|
||||
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
|
||||
import ColorPreview from "./theme-preview/color-preview";
|
||||
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
|
||||
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
|
||||
import { lazy } from "react";
|
||||
import ShadcnBlocksLogo from "@/assets/shadcnblocks.svg";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Maximize, Minimize, Moon, Sun } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ThemeEditorPreviewProps } from "@/types/theme";
|
||||
import { Maximize, Minimize, Moon, MoreVertical, Sun } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import ShadcnBlocksLogo from "@/assets/shadcnblocks.svg";
|
||||
import { lazy, useState } from "react";
|
||||
import { HorizontalScrollArea } from "../horizontal-scroll-area";
|
||||
import { TooltipWrapper } from "../tooltip-wrapper";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import ColorPreview from "./theme-preview/color-preview";
|
||||
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
|
||||
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
|
||||
|
||||
const DemoCards = lazy(() => import("@/components/examples/demo-cards"));
|
||||
const DemoCards = lazy(() => import("@/components/examples/cards"));
|
||||
const DemoMail = lazy(() => import("@/components/examples/mail"));
|
||||
const DemoTasks = lazy(() => import("@/components/examples/tasks"));
|
||||
const DemoMusic = lazy(() => import("@/components/examples/music"));
|
||||
const DemoDashboard = lazy(() => import("@/components/examples/dashboard"));
|
||||
const DemoPricing = lazy(() => import("@/components/examples/pricing/pricing"));
|
||||
const TypographyDemo = lazy(() => import("@/components/examples/typography/typography-demo"));
|
||||
|
||||
const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) => {
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState("cards");
|
||||
|
||||
if (!styles || !styles[currentMode]) {
|
||||
return null;
|
||||
@@ -36,6 +45,10 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
|
||||
toggleTheme({ x, y });
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -44,63 +57,89 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
|
||||
isFullscreen && "bg-background fixed inset-0 z-50"
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue="cards" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="mt-2 flex items-center justify-between px-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<HorizontalScrollArea className="mt-2 mb-1 flex w-full items-center justify-between px-4">
|
||||
<TabsList className="bg-background text-muted-foreground inline-flex w-fit items-center justify-center rounded-full px-0">
|
||||
<TabsTriggerPill value="cards">Cards</TabsTriggerPill>
|
||||
<div className="hidden md:flex">
|
||||
<TabsTriggerPill value="dashboard">Dashboard</TabsTriggerPill>
|
||||
<TabsTriggerPill value="pricing">Pricing</TabsTriggerPill>
|
||||
<TabsTriggerPill value="mail">Mail</TabsTriggerPill>
|
||||
<TabsTriggerPill value="tasks">Tasks</TabsTriggerPill>
|
||||
<TabsTriggerPill value="music">Music</TabsTriggerPill>
|
||||
</div>
|
||||
<TabsTriggerPill value="pricing">Pricing</TabsTriggerPill>
|
||||
<TabsTriggerPill value="colors">Color Palette</TabsTriggerPill>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipWrapper label="More previews" asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleTabChange("typography")}>
|
||||
Typography
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTabChange("tasks")}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
Tasks
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTabChange("music")}
|
||||
className="hidden md:flex"
|
||||
>
|
||||
Music
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-center gap-0">
|
||||
{isFullscreen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleThemeToggle}
|
||||
className="group h-8"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Sun className="size-4 transition-all group-hover:scale-120" />
|
||||
) : (
|
||||
<Moon className="size-4 transition-all group-hover:scale-120" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle Theme</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TooltipWrapper label="Toggle Theme" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleFullscreen}
|
||||
className="group h-8"
|
||||
onClick={handleThemeToggle}
|
||||
className="group size-8"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="size-4 transition-all group-hover:scale-120" />
|
||||
{theme === "light" ? (
|
||||
<Sun className="transition-all group-hover:scale-120" />
|
||||
) : (
|
||||
<Maximize className="size-4 transition-all group-hover:scale-120" />
|
||||
<Moon className="transition-all group-hover:scale-120" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isFullscreen ? "Exit full screen" : "Full screen"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<TooltipWrapper
|
||||
label={isFullscreen ? "Exit full screen" : "Full screen"}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleFullscreen}
|
||||
className="group size-8"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="transition-all group-hover:scale-120" />
|
||||
) : (
|
||||
<Maximize className="transition-all group-hover:scale-120" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalScrollArea>
|
||||
|
||||
<ScrollArea className="relative m-4 mt-2 flex flex-1 flex-col overflow-hidden rounded-lg border">
|
||||
<ScrollArea className="relative m-4 mt-1 flex flex-1 flex-col overflow-hidden rounded-lg border">
|
||||
<div className="flex h-full flex-1 flex-col">
|
||||
<TabsContent value="cards" className="my-4 h-full space-y-6 px-4">
|
||||
<TabsContent value="cards" className="m-0 h-full space-y-6 p-2 md:p-4">
|
||||
<ExamplesPreviewContainer>
|
||||
<DemoCards />
|
||||
</ExamplesPreviewContainer>
|
||||
@@ -157,6 +196,12 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
|
||||
</ExamplesPreviewContainer>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="typography" className="space-y-6 p-4">
|
||||
<ExamplesPreviewContainer>
|
||||
<TypographyDemo />
|
||||
</ExamplesPreviewContainer>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="colors" className="space-y-6 p-4">
|
||||
<ColorPreview styles={styles} currentMode={currentMode} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeEditorPreviewProps } from "@/types/theme";
|
||||
|
||||
interface ColorPreviewProps {
|
||||
@@ -5,18 +7,21 @@ interface ColorPreviewProps {
|
||||
currentMode: ThemeEditorPreviewProps["currentMode"];
|
||||
}
|
||||
|
||||
const renderColorPreview = (label: string, color: string) => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-md border"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{color}</p>
|
||||
function ColorPreviewItem({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<div className="group/color-preview hover:bg-muted relative flex items-center gap-4 rounded-md transition-colors">
|
||||
<div className="h-12 w-12 rounded-md border" style={{ backgroundColor: color }} />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium @max-3xl:text-xs">{label}</p>
|
||||
<p className="text-muted-foreground text-xs">{color}</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-1 rounded-md opacity-0 transition-opacity group-hover/color-preview:opacity-100">
|
||||
<CopyButton textToCopy={color} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => {
|
||||
if (!styles || !styles[currentMode]) {
|
||||
@@ -24,133 +29,139 @@ const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<div className="@container grid grid-cols-1 gap-4 md:gap-8">
|
||||
{/* Primary Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">Primary Theme Colors</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Background", styles[currentMode].background)}
|
||||
{renderColorPreview("Foreground", styles[currentMode].foreground)}
|
||||
{renderColorPreview("Primary", styles[currentMode].primary)}
|
||||
{renderColorPreview(
|
||||
"Primary Foreground",
|
||||
styles[currentMode]["primary-foreground"]
|
||||
)}
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">Primary Theme Colors</h3>
|
||||
<div className="@6xl grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Background" color={styles[currentMode].background} />
|
||||
<ColorPreviewItem label="Foreground" color={styles[currentMode].foreground} />
|
||||
<ColorPreviewItem label="Primary" color={styles[currentMode].primary} />
|
||||
<ColorPreviewItem
|
||||
label="Primary Foreground"
|
||||
color={styles[currentMode]["primary-foreground"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Secondary & Accent Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
|
||||
Secondary & Accent Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Secondary", styles[currentMode].secondary)}
|
||||
{renderColorPreview(
|
||||
"Secondary Foreground",
|
||||
styles[currentMode]["secondary-foreground"]
|
||||
)}
|
||||
{renderColorPreview("Accent", styles[currentMode].accent)}
|
||||
{renderColorPreview(
|
||||
"Accent Foreground",
|
||||
styles[currentMode]["accent-foreground"]
|
||||
)}
|
||||
<div className="@6xl grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Secondary" color={styles[currentMode].secondary} />
|
||||
<ColorPreviewItem
|
||||
label="Secondary Foreground"
|
||||
color={styles[currentMode]["secondary-foreground"]}
|
||||
/>
|
||||
<ColorPreviewItem label="Accent" color={styles[currentMode].accent} />
|
||||
<ColorPreviewItem
|
||||
label="Accent Foreground"
|
||||
color={styles[currentMode]["accent-foreground"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* UI Component Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">UI Component Colors</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Card", styles[currentMode].card)}
|
||||
{renderColorPreview(
|
||||
"Card Foreground",
|
||||
styles[currentMode]["card-foreground"]
|
||||
)}
|
||||
{renderColorPreview("Popover", styles[currentMode].popover)}
|
||||
{renderColorPreview(
|
||||
"Popover Foreground",
|
||||
styles[currentMode]["popover-foreground"]
|
||||
)}
|
||||
{renderColorPreview("Muted", styles[currentMode].muted)}
|
||||
{renderColorPreview(
|
||||
"Muted Foreground",
|
||||
styles[currentMode]["muted-foreground"]
|
||||
)}
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">UI Component Colors</h3>
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Card" color={styles[currentMode].card} />
|
||||
<ColorPreviewItem
|
||||
label="Card Foreground"
|
||||
color={styles[currentMode]["card-foreground"]}
|
||||
/>
|
||||
<ColorPreviewItem label="Popover" color={styles[currentMode].popover} />
|
||||
<ColorPreviewItem
|
||||
label="Popover Foreground"
|
||||
color={styles[currentMode]["popover-foreground"]}
|
||||
/>
|
||||
<ColorPreviewItem label="Muted" color={styles[currentMode].muted} />
|
||||
<ColorPreviewItem
|
||||
label="Muted Foreground"
|
||||
color={styles[currentMode]["muted-foreground"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Utility & Form Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">Utility & Form Colors</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Border", styles[currentMode].border)}
|
||||
{renderColorPreview("Input", styles[currentMode].input)}
|
||||
{renderColorPreview("Ring", styles[currentMode].ring)}
|
||||
{renderColorPreview("Radius", styles[currentMode].radius)}
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">Utility & Form Colors</h3>
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Border" color={styles[currentMode].border} />
|
||||
<ColorPreviewItem label="Input" color={styles[currentMode].input} />
|
||||
<ColorPreviewItem label="Ring" color={styles[currentMode].ring} />
|
||||
<ColorPreviewItem label="Radius" color={styles[currentMode].radius} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Status & Feedback Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
|
||||
Status & Feedback Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Destructive", styles[currentMode].destructive)}
|
||||
{renderColorPreview(
|
||||
"Destructive Foreground",
|
||||
styles[currentMode]["destructive-foreground"]
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Destructive" color={styles[currentMode].destructive} />
|
||||
<ColorPreviewItem
|
||||
label="Destructive Foreground"
|
||||
color={styles[currentMode]["destructive-foreground"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Chart & Data Visualization Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
|
||||
Chart & Visualization Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Chart 1", styles[currentMode]["chart-1"])}
|
||||
{renderColorPreview("Chart 2", styles[currentMode]["chart-2"])}
|
||||
{renderColorPreview("Chart 3", styles[currentMode]["chart-3"])}
|
||||
{renderColorPreview("Chart 4", styles[currentMode]["chart-4"])}
|
||||
{renderColorPreview("Chart 5", styles[currentMode]["chart-5"])}
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Chart 1" color={styles[currentMode]["chart-1"]} />
|
||||
<ColorPreviewItem label="Chart 2" color={styles[currentMode]["chart-2"]} />
|
||||
<ColorPreviewItem label="Chart 3" color={styles[currentMode]["chart-3"]} />
|
||||
<ColorPreviewItem label="Chart 4" color={styles[currentMode]["chart-4"]} />
|
||||
<ColorPreviewItem label="Chart 5" color={styles[currentMode]["chart-5"]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Sidebar Colors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">
|
||||
<h3 className="text-muted-foreground pb-2 text-sm font-semibold">
|
||||
Sidebar & Navigation Colors
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderColorPreview("Sidebar Background", styles[currentMode].sidebar)}
|
||||
{renderColorPreview(
|
||||
"Sidebar Foreground",
|
||||
styles[currentMode]["sidebar-foreground"]
|
||||
)}
|
||||
{renderColorPreview(
|
||||
"Sidebar Primary",
|
||||
styles[currentMode]["sidebar-primary"]
|
||||
)}
|
||||
{renderColorPreview(
|
||||
"Sidebar Primary Foreground",
|
||||
styles[currentMode]["sidebar-primary-foreground"]
|
||||
)}
|
||||
{renderColorPreview(
|
||||
"Sidebar Accent",
|
||||
styles[currentMode]["sidebar-accent"]
|
||||
)}
|
||||
{renderColorPreview(
|
||||
"Sidebar Accent Foreground",
|
||||
styles[currentMode]["sidebar-accent-foreground"]
|
||||
)}
|
||||
{renderColorPreview(
|
||||
"Sidebar Border",
|
||||
styles[currentMode]["sidebar-border"]
|
||||
)}
|
||||
{renderColorPreview("Sidebar Ring", styles[currentMode]["sidebar-ring"])}
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4">
|
||||
<ColorPreviewItem label="Sidebar Background" color={styles[currentMode].sidebar} />
|
||||
<ColorPreviewItem
|
||||
label="Sidebar Foreground"
|
||||
color={styles[currentMode]["sidebar-foreground"]}
|
||||
/>
|
||||
<ColorPreviewItem
|
||||
label="Sidebar Primary"
|
||||
color={styles[currentMode]["sidebar-primary"]}
|
||||
/>
|
||||
<ColorPreviewItem
|
||||
label="Sidebar Primary Foreground"
|
||||
color={styles[currentMode]["sidebar-primary-foreground"]}
|
||||
/>
|
||||
<ColorPreviewItem label="Sidebar Accent" color={styles[currentMode]["sidebar-accent"]} />
|
||||
<ColorPreviewItem
|
||||
label="Sidebar Accent Foreground"
|
||||
color={styles[currentMode]["sidebar-accent-foreground"]}
|
||||
/>
|
||||
<ColorPreviewItem label="Sidebar Border" color={styles[currentMode]["sidebar-border"]} />
|
||||
<ColorPreviewItem label="Sidebar Ring" color={styles[currentMode]["sidebar-ring"]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ const ExamplesPreviewContainer = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
<div className="mt-0 h-full space-y-6">
|
||||
<div className="@container mt-0 h-full w-full space-y-6">
|
||||
<Suspense fallback={<LoadingSkeleton />}>{children}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TabsTriggerProps } from "@radix-ui/react-tabs";
|
||||
|
||||
const TabsTriggerPill = ({ children, ...props }: TabsTriggerProps) => {
|
||||
const TabsTriggerPill = ({ children, className, ...props }: TabsTriggerProps) => {
|
||||
return (
|
||||
<TabsTrigger
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground hover:text-muted-foreground/70"
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground hover:text-muted-foreground/70 inline-flex items-center justify-center rounded-full px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { MinusIcon, PlusIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Bar, BarChart } from "recharts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
|
||||
const data = [
|
||||
{
|
||||
goal: 400,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 278,
|
||||
},
|
||||
{
|
||||
goal: 189,
|
||||
},
|
||||
{
|
||||
goal: 239,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 278,
|
||||
},
|
||||
{
|
||||
goal: 189,
|
||||
},
|
||||
{
|
||||
goal: 349,
|
||||
},
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
goal: {
|
||||
label: "Goal",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function CardsActivityGoal() {
|
||||
const [goal, setGoal] = React.useState(350);
|
||||
|
||||
function onClick(adjustment: number) {
|
||||
setGoal(Math.max(200, Math.min(400, goal + adjustment)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Move Goal</CardTitle>
|
||||
<CardDescription>Set your daily activity goal.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-7 rounded-full"
|
||||
onClick={() => onClick(-10)}
|
||||
disabled={goal <= 200}
|
||||
>
|
||||
<MinusIcon />
|
||||
<span className="sr-only">Decrease</span>
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold tracking-tighter tabular-nums">{goal}</div>
|
||||
<div className="text-muted-foreground text-xs uppercase">Calories/day</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-7 rounded-full"
|
||||
onClick={() => onClick(10)}
|
||||
disabled={goal >= 400}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="sr-only">Increase</span>
|
||||
</Button>
|
||||
</div>
|
||||
<ChartContainer config={chartConfig} className="max-h-20 w-full">
|
||||
<BarChart data={data}>
|
||||
<Bar dataKey="goal" radius={4} fill="var(--color-goal)" />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" variant="secondary">
|
||||
Set Goal
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { addDays } from "date-fns";
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const start = new Date(2025, 5, 5);
|
||||
|
||||
export function CardsCalendar() {
|
||||
return (
|
||||
<Card className="hidden p-0 @2xl:flex">
|
||||
<CardContent className="p-0">
|
||||
<Calendar
|
||||
numberOfMonths={1}
|
||||
mode="range"
|
||||
defaultMonth={start}
|
||||
selected={{
|
||||
from: start,
|
||||
to: addDays(start, 8),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, Plus, Send } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
@@ -22,12 +22,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUpIcon, CheckIcon, PlusIcon } from "lucide-react";
|
||||
|
||||
const users = [
|
||||
{
|
||||
@@ -59,7 +56,7 @@ const users = [
|
||||
|
||||
type User = (typeof users)[number];
|
||||
|
||||
export function DemoChat() {
|
||||
export function CardsChat() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = React.useState<User[]>([]);
|
||||
|
||||
@@ -88,14 +85,14 @@ export function DemoChat() {
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="border">
|
||||
<AvatarImage src="/avatars/01.png" alt="Image" />
|
||||
<AvatarFallback>OM</AvatarFallback>
|
||||
<AvatarFallback>S</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">Sofia Davis</p>
|
||||
<p className="text-sm text-muted-foreground">m@example.com</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm leading-none font-medium">Sofia Davis</p>
|
||||
<p className="text-muted-foreground text-xs">m@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -103,11 +100,11 @@ export function DemoChat() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="ml-auto rounded-full"
|
||||
variant="secondary"
|
||||
className="ml-auto size-8 rounded-full"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Plus />
|
||||
<PlusIcon />
|
||||
<span className="sr-only">New message</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -116,15 +113,15 @@ export function DemoChat() {
|
||||
</TooltipProvider>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-max max-w-[75%] flex-col gap-2 rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "ml-auto bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
? "bg-primary text-primary-foreground ml-auto"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
@@ -146,26 +143,31 @@ export function DemoChat() {
|
||||
]);
|
||||
setInput("");
|
||||
}}
|
||||
className="flex w-full items-center space-x-2"
|
||||
className="relative w-full"
|
||||
>
|
||||
<Input
|
||||
id="message"
|
||||
placeholder="Type your message..."
|
||||
className="flex-1"
|
||||
className="flex-1 pr-10"
|
||||
autoComplete="off"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={inputLength === 0}>
|
||||
<Send />
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="absolute top-1/2 right-2 size-6 -translate-y-1/2 rounded-full"
|
||||
disabled={inputLength === 0}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
<span className="sr-only">Send</span>
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="gap-0 p-0 outline-hidden">
|
||||
<DialogHeader className="px-4 pb-4 pt-5">
|
||||
<DialogContent className="gap-0 p-0 outline-none">
|
||||
<DialogHeader className="px-4 pt-5 pb-4">
|
||||
<DialogTitle>New message</DialogTitle>
|
||||
<DialogDescription>
|
||||
Invite a user to this thread. This will create a new group message.
|
||||
@@ -175,63 +177,56 @@ export function DemoChat() {
|
||||
<CommandInput placeholder="Search user..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup className="p-2">
|
||||
<CommandGroup>
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.email}
|
||||
className="flex items-center px-2"
|
||||
data-active={selectedUsers.includes(user)}
|
||||
className="data-[active=true]:opacity-50"
|
||||
onSelect={() => {
|
||||
if (selectedUsers.includes(user)) {
|
||||
return setSelectedUsers(
|
||||
selectedUsers.filter(
|
||||
(selectedUser) => selectedUser !== user,
|
||||
),
|
||||
selectedUsers.filter((selectedUser) => selectedUser !== user)
|
||||
);
|
||||
}
|
||||
|
||||
return setSelectedUsers(
|
||||
[...users].filter((u) =>
|
||||
[...selectedUsers, user].includes(u),
|
||||
),
|
||||
[...users].filter((u) => [...selectedUsers, user].includes(u))
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Avatar>
|
||||
<Avatar className="border">
|
||||
<AvatarImage src={user.avatar} alt="Image" />
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
<p className="text-sm leading-none font-medium">{user.name}</p>
|
||||
<p className="text-muted-foreground text-sm">{user.email}</p>
|
||||
</div>
|
||||
{selectedUsers.includes(user) ? (
|
||||
<Check className="ml-auto flex h-5 w-5 text-primary" />
|
||||
<CheckIcon className="text-primary ml-auto flex size-4" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<DialogFooter className="flex items-center border-t p-4 sm:justify-between">
|
||||
<DialogFooter className="flex items-center border-t p-4 @2xl:justify-between">
|
||||
{selectedUsers.length > 0 ? (
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{selectedUsers.map((user) => (
|
||||
<Avatar
|
||||
key={user.email}
|
||||
className="inline-block border-2 border-background"
|
||||
>
|
||||
<Avatar key={user.email} className="inline-block border">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select users to add to this thread.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">Select users to add to this thread.</p>
|
||||
)}
|
||||
<Button
|
||||
disabled={selectedUsers.length < 2}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function DemoCookieSettings() {
|
||||
export function CardsCookieSettings() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -18,33 +20,23 @@ export function DemoCookieSettings() {
|
||||
<CardDescription>Manage your cookie settings here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Label htmlFor="necessary" className="flex flex-col space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="necessary" className="flex flex-col items-start">
|
||||
<span>Strictly Necessary</span>
|
||||
<span className="font-normal leading-snug text-muted-foreground">
|
||||
These cookies are essential in order to use the website and use its
|
||||
features.
|
||||
<span className="text-muted-foreground leading-snug font-normal">
|
||||
These cookies are essential in order to use the website and use its features.
|
||||
</span>
|
||||
</Label>
|
||||
<Switch id="necessary" defaultChecked />
|
||||
<Switch id="necessary" defaultChecked aria-label="Necessary" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Label htmlFor="functional" className="flex flex-col space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="functional" className="flex flex-col items-start">
|
||||
<span>Functional Cookies</span>
|
||||
<span className="font-normal leading-snug text-muted-foreground">
|
||||
<span className="text-muted-foreground leading-snug font-normal">
|
||||
These cookies allow the website to provide personalized functionality.
|
||||
</span>
|
||||
</Label>
|
||||
<Switch id="functional" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Label htmlFor="performance" className="flex flex-col space-y-1">
|
||||
<span>Performance Cookies</span>
|
||||
<span className="font-normal leading-snug text-muted-foreground">
|
||||
These cookies help to improve the performance of the website.
|
||||
</span>
|
||||
</Label>
|
||||
<Switch id="performance" />
|
||||
<Switch id="functional" aria-label="Functional" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icons } from "@/components/icons";
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -11,23 +12,31 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function DemoCreateAccount() {
|
||||
export function CardsCreateAccount() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to create your account
|
||||
</CardDescription>
|
||||
<CardDescription>Enter your email below to create your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Button variant="outline">
|
||||
<Icons.gitHub className="mr-2 h-4 w-4" />
|
||||
Github
|
||||
<svg viewBox="0 0 438.549 438.549">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||
></path>
|
||||
</svg>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Icons.google className="mr-2 h-4 w-4" />
|
||||
<svg role="img" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
</div>
|
||||
@@ -36,18 +45,16 @@ export function DemoCreateAccount() {
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
<span className="bg-card text-muted-foreground px-2">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="email-create-account">Email</Label>
|
||||
<Input id="email-create-account" type="email" placeholder="m@example.com" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="password-create-account">Password</Label>
|
||||
<Input id="password-create-account" type="password" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
import * as React from "react";
|
||||
"use client";
|
||||
|
||||
import { addDays, format } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function DatePickerWithRange({
|
||||
className,
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
export function DatePickerWithRange({ className }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
const [date, setDate] = React.useState<DateRange | undefined>({
|
||||
from: new Date(2022, 0, 20),
|
||||
to: addDays(new Date(2022, 0, 20), 20),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[300px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon />
|
||||
{date?.from ? (
|
||||
date.to ? (
|
||||
<>
|
||||
{format(date.from, "LLL dd, y")} - {format(date.to, "LLL dd, y")}
|
||||
</>
|
||||
<Card className={cn("grid gap-2", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>Date picker with range</CardTitle>
|
||||
<CardDescription>Select a date range.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[300px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon />
|
||||
{date?.from ? (
|
||||
date.to ? (
|
||||
<>
|
||||
{format(date.from, "LLL dd, y")} - {format(date.to, "LLL dd, y")}
|
||||
</>
|
||||
) : (
|
||||
format(date.from, "LLL dd, y")
|
||||
)
|
||||
) : (
|
||||
format(date.from, "LLL dd, y")
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={date?.from}
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={date?.from}
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import DatePickerWithRange from "./date-picker-with-range";
|
||||
|
||||
export function DemoDatePicker() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date" className="shrink-0">
|
||||
Pick a date
|
||||
</Label>
|
||||
<DatePickerWithRange className="[&>button]:w-[260px]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { CartesianGrid, Line, LineChart, XAxis } from "recharts";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
const data = [
|
||||
{
|
||||
average: 400,
|
||||
today: 240,
|
||||
day: "Monday",
|
||||
},
|
||||
{
|
||||
average: 300,
|
||||
today: 139,
|
||||
day: "Tuesday",
|
||||
},
|
||||
{
|
||||
average: 200,
|
||||
today: 980,
|
||||
day: "Wednesday",
|
||||
},
|
||||
{
|
||||
average: 278,
|
||||
today: 390,
|
||||
day: "Thursday",
|
||||
},
|
||||
{
|
||||
average: 189,
|
||||
today: 480,
|
||||
day: "Friday",
|
||||
},
|
||||
{
|
||||
average: 239,
|
||||
today: 380,
|
||||
day: "Saturday",
|
||||
},
|
||||
{
|
||||
average: 349,
|
||||
today: 430,
|
||||
day: "Sunday",
|
||||
},
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
today: {
|
||||
label: "Today",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
average: {
|
||||
label: "Average",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function CardsExerciseMinutes() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exercise Minutes</CardTitle>
|
||||
<CardDescription>
|
||||
Your exercise minutes are ahead of where you normally are.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="w-full @3xl:h-[200px]">
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 10,
|
||||
left: 16,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="today"
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-today)"
|
||||
dot={{
|
||||
fill: "var(--color-today)",
|
||||
}}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
strokeWidth={2}
|
||||
dataKey="average"
|
||||
stroke="var(--color-average)"
|
||||
strokeOpacity={0.5}
|
||||
dot={{
|
||||
fill: "var(--color-average)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function DemoFontShowcase() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Font Showcase</CardTitle>
|
||||
<CardDescription>View theme fonts in different styles</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Sans-Serif</h3>
|
||||
<div className="font-sans space-y-1">
|
||||
<div className="text-xl font-light">Light Weight Text</div>
|
||||
<div className="text-xl">Regular Weight Text</div>
|
||||
<div className="text-xl font-medium">Medium Weight Text</div>
|
||||
<div className="text-xl font-semibold">Semibold Weight Text</div>
|
||||
<div className="text-xl font-bold">Bold Weight Text</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Serif</h3>
|
||||
<div className="font-serif space-y-1">
|
||||
<div className="text-xl font-light">Light Weight Text</div>
|
||||
<div className="text-xl">Regular Weight Text</div>
|
||||
<div className="text-xl font-medium">Medium Weight Text</div>
|
||||
<div className="text-xl font-semibold">Semibold Weight Text</div>
|
||||
<div className="text-xl font-bold">Bold Weight Text</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Monospace</h3>
|
||||
<div className="font-mono space-y-1">
|
||||
<div className="text-xl font-light">Light Weight Text</div>
|
||||
<div className="text-xl">Regular Weight Text</div>
|
||||
<div className="text-xl font-medium">Medium Weight Text</div>
|
||||
<div className="text-xl font-semibold">Semibold Weight Text</div>
|
||||
<div className="text-xl font-bold">Bold Weight Text</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter Plan",
|
||||
description: "Perfect for small businesses.",
|
||||
price: "$10",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro Plan",
|
||||
description: "More features and storage.",
|
||||
price: "$20",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function CardsForms() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Upgrade your subscription</CardTitle>
|
||||
<CardDescription className="text-balance">
|
||||
You are currently on the free plan. Upgrade to the pro plan to get access to all features.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 @3xl:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" placeholder="Evil Rabbit" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" placeholder="example@acme.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-number">Card Number</Label>
|
||||
<div className="grid grid-cols-2 gap-3 @3xl:grid-cols-[1fr_80px_60px]">
|
||||
<Input
|
||||
id="card-number"
|
||||
placeholder="1234 1234 1234 1234"
|
||||
className="col-span-2 @3xl:col-span-1"
|
||||
/>
|
||||
<Input id="card-number-expiry" placeholder="MM/YY" />
|
||||
<Input id="card-number-cvc" placeholder="CVC" />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-medium">Plan</legend>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select the plan that best fits your needs.
|
||||
</p>
|
||||
<RadioGroup defaultValue="starter" className="grid gap-3 @3xl:grid-cols-2">
|
||||
{plans.map((plan) => (
|
||||
<Label
|
||||
className="has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-input/20 flex items-start gap-3 rounded-lg border p-3"
|
||||
key={plan.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={plan.id}
|
||||
id={plan.name}
|
||||
className="data-[state=checked]:border-primary"
|
||||
/>
|
||||
<div className="grid gap-1 font-normal">
|
||||
<div className="font-medium">{plan.name}</div>
|
||||
<div className="text-muted-foreground text-xs leading-snug text-balance">
|
||||
{plan.description}
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea id="notes" placeholder="Enter notes" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="terms" />
|
||||
<Label htmlFor="terms" className="font-normal">
|
||||
I agree to the terms and conditions
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="newsletter" defaultChecked />
|
||||
<Label htmlFor="newsletter" className="font-normal">
|
||||
Allow us to send you emails
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm">Upgrade Plan</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,35 @@
|
||||
import { ChevronDown, Circle, Plus, Star } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import { Circle, Star } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
export function DemoGithub() {
|
||||
export function GithubCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="grid grid-cols-[1fr_110px] items-start gap-4 space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>tweakcn</CardTitle>
|
||||
<CardDescription>
|
||||
A visual editor for shadcn/ui components with beautiful themes.
|
||||
Accessible. Customizable. Open Source.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 rounded-md bg-secondary text-secondary-foreground">
|
||||
<Link href="https://github.com/jnsahaj/tweakcn">
|
||||
<Button variant="secondary" className="px-3 shadow-none">
|
||||
<Star />
|
||||
Star
|
||||
</Button>
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-[20px]" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" className="px-2 shadow-none">
|
||||
<ChevronDown className="text-secondary-foreground" />
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>tweakcn</CardTitle>
|
||||
<CardDescription>
|
||||
A visual editor for shadcn/ui components with beautiful themes. Accessible.
|
||||
Customizable. Open Source.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="bg-secondary text-secondary-foreground flex min-w-20 shrink-0 items-center space-x-1 rounded-md">
|
||||
<Link href="https://github.com/jnsahaj/tweakcn">
|
||||
<Button variant="secondary" className="flex items-center gap-2 px-3 shadow-none">
|
||||
<Star />
|
||||
Star
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
alignOffset={-5}
|
||||
className="w-[200px]"
|
||||
forceMount
|
||||
>
|
||||
<DropdownMenuLabel>Suggested Lists</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>
|
||||
Future Ideas
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>My Stack</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Inspiration</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Plus /> Create List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-4 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<Circle className="mr-1 h-3 w-3 fill-sky-400 text-sky-400" />
|
||||
TypeScript
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CardsActivityGoal } from "@/components/examples/cards/activity-goal";
|
||||
import { CardsCalendar } from "@/components/examples/cards/calendar";
|
||||
import { CardsChat } from "@/components/examples/cards/chat";
|
||||
import { CardsCookieSettings } from "@/components/examples/cards/cookie-settings";
|
||||
import { CardsCreateAccount } from "@/components/examples/cards/create-account";
|
||||
import { CardsExerciseMinutes } from "@/components/examples/cards/exercise-minutes";
|
||||
import { CardsForms } from "@/components/examples/cards/forms";
|
||||
import { CardsPayments } from "@/components/examples/cards/payments";
|
||||
import { CardsReportIssue } from "@/components/examples/cards/report-issue";
|
||||
import { CardsShare } from "@/components/examples/cards/share";
|
||||
import { CardsStats } from "@/components/examples/cards/stats";
|
||||
import { CardsTeamMembers } from "@/components/examples/cards/team-members";
|
||||
import { DatePickerWithRange } from "./date-picker-with-range";
|
||||
import { GithubCard } from "./github-card";
|
||||
|
||||
export default function CardsDemo() {
|
||||
return (
|
||||
<div className="@3xl:grids-col-2 grid **:data-[slot=card]:shadow-none @3xl:gap-4 @5xl:grid-cols-10 @7xl:grid-cols-11">
|
||||
<div className="grid gap-4 @5xl:col-span-4 @7xl:col-span-6">
|
||||
<CardsStats />
|
||||
<div className="grid gap-1 @2xl:grid-cols-[auto_1fr] @3xl:hidden">
|
||||
<CardsCalendar />
|
||||
<div className="@2xl:pt-0 @2xl:pl-3 @7xl:pl-4">
|
||||
<CardsActivityGoal />
|
||||
</div>
|
||||
<div className="pt-3 @2xl:col-span-2 @7xl:pt-4">
|
||||
<CardsExerciseMinutes />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 @3xl:grid-cols-2 @5xl:grid-cols-1 @7xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardsForms />
|
||||
<CardsTeamMembers />
|
||||
<CardsCookieSettings />
|
||||
<div className="hidden flex-col gap-4 @7xl:flex">
|
||||
<GithubCard />
|
||||
<DatePickerWithRange />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
<CardsCreateAccount />
|
||||
<CardsChat />
|
||||
<GithubCard />
|
||||
<DatePickerWithRange />
|
||||
|
||||
<div className="hidden @7xl:block">
|
||||
<CardsReportIssue />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 @5xl:col-span-6 @7xl:col-span-5">
|
||||
<div className="hidden gap-1 @2xl:grid-cols-[auto_1fr] @3xl:grid">
|
||||
<CardsCalendar />
|
||||
<div className="pt-3 @2xl:pt-0 @2xl:pl-3 @7xl:pl-4">
|
||||
<CardsActivityGoal />
|
||||
</div>
|
||||
<div className="pt-3 @2xl:col-span-2 @7xl:pt-3">
|
||||
<CardsExerciseMinutes />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden @3xl:block">
|
||||
<CardsPayments />
|
||||
</div>
|
||||
<CardsShare />
|
||||
<div className="@7xl:hidden">
|
||||
<CardsReportIssue />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Bell, EyeOff, User } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function DemoNotifications() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Choose what you want to be notified about.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-1">
|
||||
<div className="-mx-2 flex items-start space-x-4 rounded-md p-2 transition-all hover:bg-accent hover:text-accent-foreground">
|
||||
<Bell className="mt-px h-5 w-5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Everything</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Email digest, mentions & all activity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-2 flex items-start space-x-4 rounded-md bg-accent p-2 text-accent-foreground transition-all">
|
||||
<User className="mt-px h-5 w-5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Available</p>
|
||||
<p className="text-sm">Only mentions and comments.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-2 flex items-start space-x-4 rounded-md p-2 transition-all hover:bg-accent hover:text-accent-foreground">
|
||||
<EyeOff className="mt-px h-5 w-5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Ignoring</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Turn off all notifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icons } from "@/components/icons";
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -19,71 +20,68 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function DemoPaymentMethod() {
|
||||
const plans = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter Plan",
|
||||
description: "Perfect for small businesses.",
|
||||
price: "$10",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro Plan",
|
||||
description: "Advanced features with more storage.",
|
||||
price: "$20",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function CardsPaymentMethod() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Payment Method</CardTitle>
|
||||
<CardDescription>Add a new payment method to your account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<RadioGroup defaultValue="card" className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<RadioGroupItem value="card" id="card" className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor="card"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="mb-3 h-6 w-6"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="5" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
Card
|
||||
</Label>
|
||||
</div>
|
||||
<div>
|
||||
<RadioGroupItem value="paypal" id="paypal" className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor="paypal"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
<Icons.paypal className="mb-3 h-6 w-6" />
|
||||
Paypal
|
||||
</Label>
|
||||
</div>
|
||||
<div>
|
||||
<RadioGroupItem value="apple" id="apple" className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor="apple"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
<Icons.apple className="mb-3 h-6 w-6" />
|
||||
Apple
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className="grid gap-2">
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" placeholder="First Last" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-medium">Plan</legend>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select the plan that best fits your needs.
|
||||
</p>
|
||||
<RadioGroup defaultValue="starter" className="grid gap-3">
|
||||
{plans.map((plan) => (
|
||||
<Label
|
||||
className="has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-primary/5 flex items-start gap-3 rounded-lg border p-3"
|
||||
key={plan.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={plan.id}
|
||||
id={plan.name}
|
||||
className="data-[state=checked]:border-primary"
|
||||
/>
|
||||
<div className="grid gap-1 font-normal">
|
||||
<div className="font-medium">{plan.name}</div>
|
||||
<div className="text-muted-foreground pr-2 text-xs leading-snug text-balance">
|
||||
{plan.description}
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="number">Card number</Label>
|
||||
<Input id="number" placeholder="" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="month">Expires</Label>
|
||||
<Select>
|
||||
<SelectTrigger id="month">
|
||||
<SelectTrigger id="month" aria-label="Month" className="w-full">
|
||||
<SelectValue placeholder="Month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -102,10 +100,10 @@ export function DemoPaymentMethod() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="year">Year</Label>
|
||||
<Select>
|
||||
<SelectTrigger id="year">
|
||||
<SelectTrigger id="year" aria-label="Year" className="w-full">
|
||||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -117,7 +115,7 @@ export function DemoPaymentMethod() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="cvc">CVC</Label>
|
||||
<Input id="cvc" placeholder="CVC" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const data: Payment[] = [
|
||||
{
|
||||
id: "m5gr84i9",
|
||||
amount: 316,
|
||||
status: "success",
|
||||
email: "ken99@example.com",
|
||||
},
|
||||
{
|
||||
id: "3u1reuv4",
|
||||
amount: 242,
|
||||
status: "success",
|
||||
email: "Abe45@example.com",
|
||||
},
|
||||
{
|
||||
id: "derv1ws0",
|
||||
amount: 837,
|
||||
status: "processing",
|
||||
email: "Monserrat44@example.com",
|
||||
},
|
||||
{
|
||||
id: "bhqecj4p",
|
||||
amount: 721,
|
||||
status: "failed",
|
||||
email: "carmella@example.com",
|
||||
},
|
||||
{
|
||||
id: "k9f2m3n4",
|
||||
amount: 450,
|
||||
status: "pending",
|
||||
email: "jason78@example.com",
|
||||
},
|
||||
{
|
||||
id: "p5q6r7s8",
|
||||
amount: 1280,
|
||||
status: "success",
|
||||
email: "sarah23@example.com",
|
||||
},
|
||||
];
|
||||
|
||||
export type Payment = {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: "pending" | "processing" | "success" | "failed";
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<Payment>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <div className="capitalize">{row.getValue("status")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: () => <div className="text-right">Amount</div>,
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue("amount"));
|
||||
|
||||
// Format the amount as a dollar amount
|
||||
const formatted = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
|
||||
return <div className="text-right font-medium">{formatted}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const payment = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="size-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(payment.id)}>
|
||||
Copy payment ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>View customer</DropdownMenuItem>
|
||||
<DropdownMenuItem>View payment details</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function CardsPayments() {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Payments</CardTitle>
|
||||
<CardDescription>Manage your payments.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="data-[name=actions]:w-10 data-[name=amount]:w-24 data-[name=select]:w-10 data-[name=status]:w-24 [&:has([role=checkbox])]:pl-3"
|
||||
data-name={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="data-[name=actions]:w-10 data-[name=amount]:w-24 data-[name=select]:w-10 data-[name=status]:w-24 [&:has([role=checkbox])]:pl-3"
|
||||
data-name={cell.column.id}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+31
-18
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -18,19 +22,21 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function DemoReportAnIssue() {
|
||||
export function CardsReportIssue() {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report an issue</CardTitle>
|
||||
<CardDescription>What area are you having problems with?</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="area">Area</Label>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div className="grid gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor={`area-${id}`}>Area</Label>
|
||||
<Select defaultValue="billing">
|
||||
<SelectTrigger id="area">
|
||||
<SelectTrigger id={`area-${id}`} aria-label="Area" className="w-full">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -42,10 +48,14 @@ export function DemoReportAnIssue() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="security-level">Security Level</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor={`security-level-${id}`}>Security Level</Label>
|
||||
<Select defaultValue="2">
|
||||
<SelectTrigger id="security-level">
|
||||
<SelectTrigger
|
||||
id={`security-level-${id}`}
|
||||
className="w-full [&_span]:!block [&_span]:truncate"
|
||||
aria-label="Security Level"
|
||||
>
|
||||
<SelectValue placeholder="Select level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -57,21 +67,24 @@ export function DemoReportAnIssue() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input id="subject" placeholder="I need help with..." />
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor={`subject-${id}`}>Subject</Label>
|
||||
<Input id={`subject-${id}`} placeholder="I need help with..." />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor={`description-${id}`}>Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
id={`description-${id}`}
|
||||
placeholder="Please include all information relevant to your issue."
|
||||
className="min-h-28"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between space-x-2">
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Button>Submit</Button>
|
||||
<CardFooter className="justify-end gap-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm">Submit</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function DemoShareDocument() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Share this document</CardTitle>
|
||||
<CardDescription>
|
||||
Anyone with the link can view this document.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-2">
|
||||
<Input value="http://example.com/link/to/document" readOnly />
|
||||
<Button variant="secondary" className="shrink-0">
|
||||
Copy Link
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">People with access</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/03.png" />
|
||||
<AvatarFallback>OM</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">Olivia Martin</p>
|
||||
<p className="text-sm text-muted-foreground">m@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select defaultValue="edit">
|
||||
<SelectTrigger className="ml-auto w-[110px]">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edit">Can edit</SelectItem>
|
||||
<SelectItem value="view">Can view</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/05.png" />
|
||||
<AvatarFallback>IN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">Isabella Nguyen</p>
|
||||
<p className="text-sm text-muted-foreground">b@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select defaultValue="view">
|
||||
<SelectTrigger className="ml-auto w-[110px]">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edit">Can edit</SelectItem>
|
||||
<SelectItem value="view">Can view</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/01.png" />
|
||||
<AvatarFallback>SD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">Sofia Davis</p>
|
||||
<p className="text-sm text-muted-foreground">p@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select defaultValue="view">
|
||||
<SelectTrigger className="ml-auto w-[110px]">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edit">Can edit</SelectItem>
|
||||
<SelectItem value="view">Can view</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const people = [
|
||||
{
|
||||
name: "Olivia Martin",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/03.png",
|
||||
},
|
||||
{
|
||||
name: "Isabella Nguyen",
|
||||
email: "b@example.com",
|
||||
avatar: "/avatars/04.png",
|
||||
},
|
||||
{
|
||||
name: "Sofia Davis",
|
||||
email: "p@example.com",
|
||||
avatar: "/avatars/05.png",
|
||||
},
|
||||
{
|
||||
name: "Ethan Thompson",
|
||||
email: "e@example.com",
|
||||
avatar: "/avatars/01.png",
|
||||
},
|
||||
];
|
||||
export function CardsShare() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Share this document</CardTitle>
|
||||
<CardDescription>Anyone with the link can view this document.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="link" className="sr-only">
|
||||
Link
|
||||
</Label>
|
||||
<Input id="link" value="http://example.com/link/to/document" className="h-8" readOnly />
|
||||
<Button size="sm" variant="outline" className="shadow-none">
|
||||
Copy Link
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-sm font-medium">People with access</div>
|
||||
<div className="grid gap-6">
|
||||
{people.map((person) => (
|
||||
<div key={person.email} className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={person.avatar} alt="Image" />
|
||||
<AvatarFallback>{person.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm leading-none font-medium">{person.name}</p>
|
||||
<p className="text-muted-foreground text-sm">{person.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select defaultValue="edit">
|
||||
<SelectTrigger className="ml-auto pr-2" aria-label="Edit">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="edit">Can edit</SelectItem>
|
||||
<SelectItem value="view">Can view</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Bar, BarChart, Line, LineChart } from "recharts";
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Area, AreaChart, Line, LineChart } from "recharts";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
|
||||
const data = [
|
||||
{
|
||||
revenue: 10400,
|
||||
subscription: 240,
|
||||
subscription: 40,
|
||||
},
|
||||
{
|
||||
revenue: 14405,
|
||||
subscription: 300,
|
||||
subscription: 90,
|
||||
},
|
||||
{
|
||||
revenue: 9400,
|
||||
@@ -22,7 +24,7 @@ const data = [
|
||||
},
|
||||
{
|
||||
revenue: 7000,
|
||||
subscription: 189,
|
||||
subscription: 89,
|
||||
},
|
||||
{
|
||||
revenue: 9600,
|
||||
@@ -30,11 +32,11 @@ const data = [
|
||||
},
|
||||
{
|
||||
revenue: 11244,
|
||||
subscription: 278,
|
||||
subscription: 78,
|
||||
},
|
||||
{
|
||||
revenue: 26475,
|
||||
subscription: 189,
|
||||
subscription: 89,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -49,17 +51,17 @@ const chartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function DemoStats() {
|
||||
export function CardsStats() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
|
||||
<div className="grid gap-4 @xl:grid-cols-2 @5xl:grid-cols-1 @7xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-normal">Total Revenue</CardTitle>
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-3xl">$15,231.89</CardTitle>
|
||||
<CardDescription>+20.1% from last month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-0">
|
||||
<div className="text-2xl font-bold">$15,231.89</div>
|
||||
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
|
||||
<ChartContainer config={chartConfig} className="h-[80px] w-full">
|
||||
<ChartContainer config={chartConfig} className="h-[90px] w-full">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
@@ -82,21 +84,31 @@ export function DemoStats() {
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-normal">Subscriptions</CardTitle>
|
||||
<Card className="relative flex flex-col pb-0 @5xl:hidden @7xl:flex">
|
||||
<CardHeader>
|
||||
<CardDescription>Subscriptions</CardDescription>
|
||||
<CardTitle className="text-3xl">+2,350</CardTitle>
|
||||
<CardDescription>+180.1% from last month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+2350</div>
|
||||
<p className="text-xs text-muted-foreground">+180.1% from last month</p>
|
||||
<ChartContainer config={chartConfig} className="mt-2 h-[80px] w-full">
|
||||
<BarChart data={data}>
|
||||
<Bar
|
||||
<CardContent className="relative mt-auto flex-1 p-0">
|
||||
<ChartContainer config={chartConfig} className="relative size-full h-[90px]">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
className="size-fit"
|
||||
>
|
||||
<Area
|
||||
dataKey="subscription"
|
||||
fill="var(--color-subscription)"
|
||||
radius={4}
|
||||
fillOpacity={0.05}
|
||||
stroke="var(--color-subscription)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</BarChart>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -19,7 +15,47 @@ import {
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
export function DemoTeamMembers() {
|
||||
const teamMembers = [
|
||||
{
|
||||
name: "Sofia Davis",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/01.png",
|
||||
role: "Owner",
|
||||
},
|
||||
{
|
||||
name: "Jackson Lee",
|
||||
email: "p@example.com",
|
||||
avatar: "/avatars/02.png",
|
||||
role: "Developer",
|
||||
},
|
||||
{
|
||||
name: "Isabella Nguyen",
|
||||
email: "i@example.com",
|
||||
avatar: "/avatars/03.png",
|
||||
role: "Billing",
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: "Viewer",
|
||||
description: "Can view and comment.",
|
||||
},
|
||||
{
|
||||
name: "Developer",
|
||||
description: "Can view, comment and edit.",
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
description: "Can view, comment and manage billing.",
|
||||
},
|
||||
{
|
||||
name: "Owner",
|
||||
description: "Admin-level access to all resources.",
|
||||
},
|
||||
];
|
||||
|
||||
export function CardsTeamMembers() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -27,112 +63,45 @@ export function DemoTeamMembers() {
|
||||
<CardDescription>Invite your team members to collaborate.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/01.png" />
|
||||
<AvatarFallback>OM</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">Sofia Davis</p>
|
||||
<p className="text-sm text-muted-foreground">m@example.com</p>
|
||||
{teamMembers.map((member) => (
|
||||
<div key={member.name} className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="border">
|
||||
<AvatarImage src={member.avatar} alt="Image" />
|
||||
<AvatarFallback>{member.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm leading-none font-medium">{member.name}</p>
|
||||
<p className="text-muted-foreground text-xs">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="ml-auto shadow-none">
|
||||
{member.role} <ChevronDown />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Select role..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No roles found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{roles.map((role) => (
|
||||
<CommandItem key={role.name}>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-medium">{role.name}</p>
|
||||
<p className="text-muted-foreground">{role.description}</p>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto">
|
||||
Owner <ChevronDown className="text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Select new role..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No roles found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Viewer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Can view and comment.
|
||||
</p>
|
||||
</CommandItem>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Developer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Can view, comment and edit.
|
||||
</p>
|
||||
</CommandItem>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Billing</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Can view, comment and manage billing.
|
||||
</p>
|
||||
</CommandItem>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Owner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Admin-level access to all resources.
|
||||
</p>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/02.png" />
|
||||
<AvatarFallback>JL</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">Jackson Lee</p>
|
||||
<p className="text-sm text-muted-foreground">p@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto">
|
||||
Member <ChevronDown className="text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Select new role..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No roles found.</CommandEmpty>
|
||||
<CommandGroup className="p-1.5">
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Viewer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Can view and comment.
|
||||
</p>
|
||||
</CommandItem>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Developer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Can view, comment and edit.
|
||||
</p>
|
||||
</CommandItem>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Billing</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Can view, comment and manage billing.
|
||||
</p>
|
||||
</CommandItem>
|
||||
<CommandItem className="teamaspace-y-1 flex flex-col items-start px-4 py-2">
|
||||
<p>Owner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Admin-level access to all resources.
|
||||
</p>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DemoCookieSettings } from "./cards/cookie-settings";
|
||||
import { DemoCreateAccount } from "./cards/create-account";
|
||||
import { DemoDatePicker } from "./cards/date-picker";
|
||||
import { DemoGithub } from "./cards/github-card";
|
||||
import { DemoNotifications } from "./cards/notifications";
|
||||
import { DemoPaymentMethod } from "./cards/payment-method";
|
||||
import { DemoReportAnIssue } from "./cards/report-an-issue";
|
||||
import { DemoShareDocument } from "./cards/share-document";
|
||||
import { DemoTeamMembers } from "./cards/team-members";
|
||||
import { DemoStats } from "./cards/stats";
|
||||
import { DemoChat } from "./cards/chat";
|
||||
import { DemoFontShowcase } from "./cards/font-showcase";
|
||||
|
||||
export function DemoContainer({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center [&>div]:w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoCards() {
|
||||
return (
|
||||
<div className="@container">
|
||||
<div className="flex flex-col @4xl:flex-row items-center @4xl:items-start justify-center gap-4">
|
||||
{/* First column */}
|
||||
<div className="flex flex-col gap-4 max-w-lg">
|
||||
<DemoContainer>
|
||||
<DemoStats />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoCreateAccount />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoGithub />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoCookieSettings />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoTeamMembers />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoFontShowcase />
|
||||
</DemoContainer>
|
||||
</div>
|
||||
|
||||
{/* Third column */}
|
||||
<div className="flex flex-col gap-4 max-w-lg">
|
||||
<DemoContainer>
|
||||
<DemoReportAnIssue />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoPaymentMethod />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoShareDocument />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoNotifications />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoChat />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoDatePicker />
|
||||
</DemoContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DemoCards;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Bookmark, Calendar, Clock, Heart, MessageCircle, Share2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function BlogPost() {
|
||||
return (
|
||||
<Card className="bg-background flex min-h-screen flex-col overflow-hidden">
|
||||
<div className="bg-card flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="size-3 rounded-full bg-red-500"></div>
|
||||
<div className="size-3 rounded-full bg-yellow-500"></div>
|
||||
<div className="size-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="@container container mx-auto max-w-4xl px-4 py-8">
|
||||
<article className="space-y-8">
|
||||
{/* Header */}
|
||||
<header className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">Technology</Badge>
|
||||
<Badge variant="outline">Web Development</Badge>
|
||||
<Badge variant="outline">React</Badge>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl leading-tight font-bold tracking-tight @md:text-5xl @lg:text-6xl">
|
||||
The Future of Web Development: Embracing Modern Technologies
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||
Discover how cutting-edge technologies are reshaping the landscape of web development,
|
||||
from AI-powered tools to revolutionary frameworks that are changing how we build for
|
||||
the web.
|
||||
</p>
|
||||
|
||||
{/* Author and Meta */}
|
||||
<div className="flex flex-col justify-between gap-4 pt-4 @sm:flex-row @sm:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src="/placeholder.svg?height=48&width=48" alt="Author" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold">Jane Doe</p>
|
||||
<p className="text-muted-foreground text-sm">Senior Developer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Dec 15, 2024</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>8 min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Featured Image */}
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src="/placeholder.svg?height=600&width=1200"
|
||||
alt="Featured image"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-gray dark:prose-invert max-w-none">
|
||||
<p className="text-lg leading-relaxed">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
|
||||
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-8 mb-4 text-2xl font-bold">The Evolution of Modern Frameworks</h2>
|
||||
|
||||
<p>
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
|
||||
nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
|
||||
officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
|
||||
<blockquote className="border-primary my-6 border-l-4 pl-6 text-lg italic">
|
||||
"The best way to predict the future is to create it. In web development,
|
||||
we're not just following trends—we're setting them."
|
||||
</blockquote>
|
||||
|
||||
<h3 className="mt-6 mb-3 text-xl font-semibold">Key Technologies Shaping the Future</h3>
|
||||
|
||||
<ul className="my-4 space-y-2">
|
||||
<li>Artificial Intelligence and Machine Learning integration</li>
|
||||
<li>Edge computing and serverless architectures</li>
|
||||
<li>Progressive Web Applications (PWAs)</li>
|
||||
<li>WebAssembly for high-performance applications</li>
|
||||
<li>Advanced CSS features and container queries</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
|
||||
laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi
|
||||
architecto beatae vitae dicta sunt explicabo.
|
||||
</p>
|
||||
|
||||
<div className="my-8">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h4 className="mb-2 font-semibold">💡 Pro Tip</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Always stay updated with the latest web standards and best practices. The web
|
||||
development landscape evolves rapidly, and continuous learning is key to staying
|
||||
relevant.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 mb-4 text-2xl font-bold">Looking Ahead</h2>
|
||||
|
||||
<p>
|
||||
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
|
||||
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro
|
||||
quisquam est, qui dolorem ipsum quia dolor sit amet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium
|
||||
voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint
|
||||
occaecati cupiditate non provident.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Engagement Actions */}
|
||||
<div className="flex flex-col items-start justify-between gap-4 @sm:flex-row @sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Heart className="h-4 w-4" />
|
||||
<span>42</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
<span>12</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
<span>Share</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Bookmark className="h-4 w-4" />
|
||||
<span>Save for later</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Author Bio */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-4 @sm:flex-row">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src="/placeholder.svg?height=64&width=64" alt="Author" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Jane Doe</h3>
|
||||
<p className="text-muted-foreground mb-2">Senior Developer & Tech Writer</p>
|
||||
<p className="text-sm">
|
||||
Jane is a passionate developer with over 8 years of experience in web
|
||||
development. She specializes in React, TypeScript, and modern web technologies.
|
||||
When she's not coding, you can find her writing about the latest trends in
|
||||
tech.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="self-start">
|
||||
Follow
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function DemoFontShowcase() {
|
||||
return (
|
||||
<Card className="flex flex-1 flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Font Showcase</CardTitle>
|
||||
<CardDescription>View theme fonts in different styles</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-coloverflow-hidden flex flex-1 px-0">
|
||||
<ScrollArea className="flex size-full max-h-100 flex-1 flex-col px-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-lg font-semibold">Sans-Serif</h3>
|
||||
<div className="space-y-1 font-sans">
|
||||
<div className="text-normal line-clamp-1 font-light">Light Weight Text</div>
|
||||
<div className="text-normal line-clamp-1">Regular Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-medium">Medium Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-semibold">Semibold Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-bold">Bold Weight Text</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-lg font-semibold">Serif</h3>
|
||||
<div className="space-y-1 font-serif">
|
||||
<div className="text-normal line-clamp-1 font-light">Light Weight Text</div>
|
||||
<div className="text-normal line-clamp-1">Regular Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-medium">Medium Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-semibold">Semibold Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-bold">Bold Weight Text</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-lg font-semibold">Monospace</h3>
|
||||
<div className="space-y-1 font-mono">
|
||||
<div className="text-normal line-clamp-1 font-light">Light Weight Text</div>
|
||||
<div className="text-normal line-clamp-1">Regular Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-medium">Medium Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-semibold">Semibold Weight Text</div>
|
||||
<div className="text-normal line-clamp-1 font-bold">Bold Weight Text</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BlogPost } from "./blog-post";
|
||||
import { DemoFontShowcase } from "./font-showcase";
|
||||
|
||||
export default function TypographyDemo() {
|
||||
return (
|
||||
<div className="@container relative grid grid-cols-9 gap-4">
|
||||
<div className="sticky top-6 hidden size-full max-h-140 overflow-hidden lg:col-span-3 lg:block">
|
||||
<DemoFontShowcase />
|
||||
</div>
|
||||
<div className="col-span-9 lg:col-span-6">
|
||||
<BlogPost />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { motion } from "motion/react";
|
||||
import { defaultPresets } from "@/utils/theme-presets";
|
||||
import { DemoContainer } from "@/components/examples/demo-cards";
|
||||
import { DemoGithub } from "@/components/examples/cards/github-card";
|
||||
import { DemoStats } from "@/components/examples/cards/stats";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Loading } from "../loading";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { ThemePresetButtons } from "@/components/home/theme-preset-buttons";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { GithubCard } from "../examples/cards/github-card";
|
||||
import { CardsStats } from "../examples/cards/stats";
|
||||
import { Loading } from "../loading";
|
||||
|
||||
const DemoMail = lazy(() => import("@/components/examples/mail"));
|
||||
|
||||
@@ -18,7 +17,6 @@ export function ThemePresetSelector() {
|
||||
const { themeState, applyThemePreset } = useEditorStore();
|
||||
const mode = themeState.currentMode;
|
||||
const presetNames = Object.keys(defaultPresets);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<section id="examples" className="w-full py-20 md:py-32">
|
||||
@@ -28,22 +26,21 @@ export function ThemePresetSelector() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
|
||||
className="mb-12 flex flex-col items-center justify-center space-y-4 text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<div className="mb-4 flex items-center justify-center gap-4">
|
||||
<Badge
|
||||
className="rounded-full px-4 py-1.5 text-sm font-medium shadow-sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="mr-1 text-primary">✦</span> Theme Presets
|
||||
<span className="text-primary mr-1">✦</span> Theme Presets
|
||||
</Badge>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/80">
|
||||
<h2 className="from-foreground to-foreground/80 bg-gradient-to-r bg-clip-text text-3xl font-bold tracking-tight text-transparent md:text-4xl">
|
||||
Elevate Your Design Instantly
|
||||
</h2>
|
||||
<p className="max-w-[800px] text-muted-foreground md:text-lg">
|
||||
Apply theme presets with a single click. See how each option
|
||||
enhances the look.
|
||||
<p className="text-muted-foreground max-w-[800px] md:text-lg">
|
||||
Apply theme presets with a single click. See how each option enhances the look.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -59,28 +56,23 @@ export function ThemePresetSelector() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="@container relative overflow-hidden border rounded-lg max-h-[60vh] md:max-h-[70vh] shadow-lg bg-gradient-to-b from-card/50 to-card/30 backdrop-blur-sm"
|
||||
className="from-card/50 to-card/30 @container relative max-h-[60vh] overflow-hidden rounded-lg border bg-gradient-to-b shadow-lg backdrop-blur-sm md:max-h-[70vh]"
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-16 pointer-events-none z-10"
|
||||
className="pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-16"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(255,255,255,0), var(--background))",
|
||||
background: "linear-gradient(to bottom, rgba(255,255,255,0), var(--background))",
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{!isMobile ? (
|
||||
<div className="hidden lg:block">
|
||||
<DemoMail />
|
||||
) : (
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<DemoContainer>
|
||||
<DemoStats />
|
||||
</DemoContainer>
|
||||
<DemoContainer>
|
||||
<DemoGithub />
|
||||
</DemoContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4 lg:hidden">
|
||||
<CardsStats />
|
||||
<GithubCard />
|
||||
</div>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,6 @@ export function HorizontalScrollArea({ className, children, ...props }: Horizont
|
||||
const leftMarker = leftMarkerRef.current;
|
||||
const rightMarker = rightMarkerRef.current;
|
||||
|
||||
console.log(leftMarker, rightMarker);
|
||||
|
||||
if (!leftMarker || !rightMarker) return;
|
||||
|
||||
const observerOptions = {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { EditorConfig } from "@/types/editor";
|
||||
import { themeEditorConfig } from "./theme";
|
||||
|
||||
// Registry of all available editors
|
||||
export const editorRegistry: Record<string, EditorConfig> = {
|
||||
theme: themeEditorConfig,
|
||||
// Add more editors here as they are created
|
||||
};
|
||||
|
||||
// Helper function to get editor config by type
|
||||
export const getEditorConfig = (type: string): EditorConfig => {
|
||||
const config = editorRegistry[type];
|
||||
if (!config) {
|
||||
throw new Error(`Editor type "${type}" not found`);
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
// Export all editor types
|
||||
export const editorTypes = Object.keys(editorRegistry);
|
||||
@@ -1,14 +0,0 @@
|
||||
import { EditorConfig } from "@/types/editor";
|
||||
import ThemeControlPanel from "@/components/editor/theme-control-panel";
|
||||
import ThemePreviewPanel from "@/components/editor/theme-preview-panel";
|
||||
import { generateThemeCode } from "@/utils/theme-style-generator";
|
||||
import { defaultThemeState } from "@/config/theme";
|
||||
|
||||
export const themeEditorConfig: EditorConfig = {
|
||||
type: "theme",
|
||||
name: "Theme",
|
||||
description: "Customize your global theme colors",
|
||||
defaultState: defaultThemeState,
|
||||
controls: ThemeControlPanel,
|
||||
preview: ThemePreviewPanel,
|
||||
};
|
||||
+66
-66
@@ -26,44 +26,44 @@ export const DEFAULT_FONT_MONO =
|
||||
|
||||
// Default light theme styles
|
||||
export const defaultLightThemeStyles = {
|
||||
background: "hsl(0 0% 100%)",
|
||||
foreground: "hsl(0 0% 14.5%)",
|
||||
card: "hsl(0 0% 100%)",
|
||||
"card-foreground": "hsl(0 0% 14.5%)",
|
||||
popover: "hsl(0 0% 100%)",
|
||||
"popover-foreground": "hsl(0 0% 14.5%)",
|
||||
primary: "hsl(0 0% 20.5%)",
|
||||
"primary-foreground": "hsl(0 0% 98.5%)",
|
||||
secondary: "hsl(0 0% 97%)",
|
||||
"secondary-foreground": "hsl(0 0% 20.5%)",
|
||||
muted: "hsl(0 0% 97%)",
|
||||
"muted-foreground": "hsl(0 0% 55.6%)",
|
||||
accent: "hsl(0 0% 97%)",
|
||||
"accent-foreground": "hsl(0 0% 20.5%)",
|
||||
destructive: "hsl(0 80% 57.7%)",
|
||||
"destructive-foreground": "hsl(0 0% 100%)",
|
||||
border: "hsl(0 0% 92.2%)",
|
||||
input: "hsl(0 0% 92.2%)",
|
||||
ring: "hsl(0 0% 70.8%)",
|
||||
"chart-1": "hsl(41 78% 64.6%)",
|
||||
"chart-2": "hsl(185 68% 60%)",
|
||||
"chart-3": "hsl(227 65% 39.8%)",
|
||||
"chart-4": "hsl(84 63% 82.8%)",
|
||||
"chart-5": "hsl(70 68% 76.9%)",
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.145 0 0)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.145 0 0)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.145 0 0)",
|
||||
primary: "oklch(0.205 0 0)",
|
||||
"primary-foreground": "oklch(0.985 0 0)",
|
||||
secondary: "oklch(0.97 0 0)",
|
||||
"secondary-foreground": "oklch(0.205 0 0)",
|
||||
muted: "oklch(0.97 0 0)",
|
||||
"muted-foreground": "oklch(0.556 0 0)",
|
||||
accent: "oklch(0.97 0 0)",
|
||||
"accent-foreground": "oklch(0.205 0 0)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.922 0 0)",
|
||||
input: "oklch(0.922 0 0)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
"chart-1": "oklch(0.81 0.10 252)",
|
||||
"chart-2": "oklch(0.62 0.19 260)",
|
||||
"chart-3": "oklch(0.55 0.22 263)",
|
||||
"chart-4": "oklch(0.49 0.22 264)",
|
||||
"chart-5": "oklch(0.42 0.18 266)",
|
||||
radius: "0.625rem",
|
||||
sidebar: "hsl(0 0% 98.5%)",
|
||||
"sidebar-foreground": "hsl(0 0% 14.5%)",
|
||||
"sidebar-primary": "hsl(0 0% 20.5%)",
|
||||
"sidebar-primary-foreground": "hsl(0 0% 98.5%)",
|
||||
"sidebar-accent": "hsl(0 0% 97%)",
|
||||
"sidebar-accent-foreground": "hsl(0 0% 20.5%)",
|
||||
"sidebar-border": "hsl(0 0% 92.2%)",
|
||||
"sidebar-ring": "hsl(0 0% 70.8%)",
|
||||
sidebar: "oklch(0.985 0 0)",
|
||||
"sidebar-foreground": "oklch(0.145 0 0)",
|
||||
"sidebar-primary": "oklch(0.205 0 0)",
|
||||
"sidebar-primary-foreground": "oklch(0.985 0 0)",
|
||||
"sidebar-accent": "oklch(0.97 0 0)",
|
||||
"sidebar-accent-foreground": "oklch(0.205 0 0)",
|
||||
"sidebar-border": "oklch(0.922 0 0)",
|
||||
"sidebar-ring": "oklch(0.708 0 0)",
|
||||
"font-sans": DEFAULT_FONT_SANS,
|
||||
"font-serif": DEFAULT_FONT_SERIF,
|
||||
"font-mono": DEFAULT_FONT_MONO,
|
||||
|
||||
"shadow-color": "hsl(0 0% 0%)",
|
||||
"shadow-color": "oklch(0 0 0)",
|
||||
"shadow-opacity": "0.1",
|
||||
"shadow-blur": "3px",
|
||||
"shadow-spread": "0px",
|
||||
@@ -77,43 +77,43 @@ export const defaultLightThemeStyles = {
|
||||
// Default dark theme styles
|
||||
export const defaultDarkThemeStyles = {
|
||||
...defaultLightThemeStyles,
|
||||
background: "hsl(240 10% 3.9%)",
|
||||
foreground: "hsl(0 0% 98%)",
|
||||
card: "hsl(240 10% 3.9%)",
|
||||
"card-foreground": "hsl(0 0% 98%)",
|
||||
popover: "hsl(240 10% 3.9%)",
|
||||
"popover-foreground": "hsl(0 0% 98%)",
|
||||
primary: "hsl(0 0% 98%)",
|
||||
"primary-foreground": "hsl(240 5.9% 10%)",
|
||||
secondary: "hsl(240 3.7% 15.9%)",
|
||||
"secondary-foreground": "hsl(0 0% 98%)",
|
||||
muted: "hsl(240 3.7% 15.9%)",
|
||||
"muted-foreground": "hsl(240 5% 64.9%)",
|
||||
accent: "hsl(240 3.7% 15.9%)",
|
||||
"accent-foreground": "hsl(0 0% 98%)",
|
||||
destructive: "hsl(0 62.8% 30.6%)",
|
||||
"destructive-foreground": "hsl(0 85.7% 97.3%)",
|
||||
border: "hsl(240 3.7% 15.9%)",
|
||||
input: "hsl(240 3.7% 15.9%)",
|
||||
ring: "hsl(240 4.9% 83.9%)",
|
||||
"chart-1": "hsl(220 70% 50%)",
|
||||
"chart-2": "hsl(160 60% 45%)",
|
||||
"chart-3": "hsl(30 80% 55%)",
|
||||
"chart-4": "hsl(280 65% 60%)",
|
||||
"chart-5": "hsl(340 75% 55%)",
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.269 0 0)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.922 0 0)",
|
||||
"primary-foreground": "oklch(0.205 0 0)",
|
||||
secondary: "oklch(0.269 0 0)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
"muted-foreground": "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.371 0 0)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
"destructive-foreground": "oklch(0.985 0 0)",
|
||||
border: "oklch(0.275 0 0)", // in place of oklch(1 0 0 / 10%)
|
||||
input: "oklch(0.325 0 0)", // in place of oklch(1 0 0 / 15%)
|
||||
ring: "oklch(0.556 0 0)",
|
||||
"chart-1": "oklch(0.81 0.10 252)",
|
||||
"chart-2": "oklch(0.62 0.19 260)",
|
||||
"chart-3": "oklch(0.55 0.22 263)",
|
||||
"chart-4": "oklch(0.49 0.22 264)",
|
||||
"chart-5": "oklch(0.42 0.18 266)",
|
||||
// Actual has radius but not in Expected, keeping it as is
|
||||
radius: "0.625rem",
|
||||
// Converting sidebar-related variables to match Actual format
|
||||
sidebar: "hsl(240 5.9% 10%)",
|
||||
"sidebar-foreground": "hsl(240 4.8% 95.9%)",
|
||||
"sidebar-primary": "hsl(224.3 76.3% 48%)",
|
||||
"sidebar-primary-foreground": "hsl(0 0% 100%)",
|
||||
"sidebar-accent": "hsl(240 3.7% 15.9%)",
|
||||
"sidebar-accent-foreground": "hsl(240 4.8% 95.9%)",
|
||||
"sidebar-border": "hsl(240 3.7% 15.9%)",
|
||||
"sidebar-ring": "hsl(240 4.9% 83.9%)",
|
||||
sidebar: "oklch(0.205 0 0)",
|
||||
"sidebar-foreground": "oklch(0.985 0 0)",
|
||||
"sidebar-primary": "oklch(0.488 0.243 264.376)",
|
||||
"sidebar-primary-foreground": "oklch(0.985 0 0)",
|
||||
"sidebar-accent": "oklch(0.269 0 0)",
|
||||
"sidebar-accent-foreground": "oklch(0.985 0 0)",
|
||||
"sidebar-border": "oklch(0.275 0 0)", // in place of oklch(1 0 0 / 10%)
|
||||
"sidebar-ring": "oklch(0.439 0 0)",
|
||||
|
||||
"shadow-color": "hsl(0 0% 0%)",
|
||||
"shadow-color": "oklch(0 0 0)",
|
||||
|
||||
"letter-spacing": "0em",
|
||||
spacing: "0.25rem",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useAIChatStore } from "@/store/ai-chat-store";
|
||||
|
||||
export function useAIChat() {
|
||||
const store = useAIChatStore();
|
||||
return store;
|
||||
}
|
||||
@@ -1,72 +1,42 @@
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { GenerateThemeOptions, useAIThemeGenerationStore } from "@/store/ai-theme-generation-store";
|
||||
import { useAIThemeGenerationStore } from "@/store/ai-theme-generation-store";
|
||||
|
||||
// Hook for components that only need generation functionality
|
||||
export function useAIThemeGeneration() {
|
||||
const _generateTheme = useAIThemeGenerationStore((state) => state.generateTheme);
|
||||
const generateTheme = useAIThemeGenerationStore((state) => state.generateTheme);
|
||||
const loading = useAIThemeGenerationStore((state) => state.loading);
|
||||
const cancelThemeGeneration = useAIThemeGenerationStore((state) => state.cancelThemeGeneration);
|
||||
|
||||
const generateTheme = async (options?: GenerateThemeOptions) => {
|
||||
return await _generateTheme({
|
||||
...options,
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Theme generated",
|
||||
description: "Your AI-generated theme has been applied",
|
||||
});
|
||||
options?.onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
toast({
|
||||
title: "Theme generation cancelled",
|
||||
description: "The theme generation was cancelled, no changes were made.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleGenerateTheme = async (prompt: string) => {
|
||||
try {
|
||||
const result = await generateTheme(prompt);
|
||||
|
||||
toast({
|
||||
title: "Theme generated",
|
||||
description: "Your AI-generated theme has been applied",
|
||||
});
|
||||
|
||||
return result; // Return the result from the store
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
toast({
|
||||
title: "Theme generation cancelled",
|
||||
description: "The theme generation was cancelled, no changes were made.",
|
||||
});
|
||||
} else {
|
||||
const description =
|
||||
error instanceof Error ? error.message : "Failed to generate theme. Please try again.";
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate theme. Please try again.",
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
options?.onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
generateTheme,
|
||||
generateTheme: handleGenerateTheme,
|
||||
loading,
|
||||
cancelThemeGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for components that only need prompt-related state and actions
|
||||
export function useAIThemeGenerationPrompts() {
|
||||
const prompt = useAIThemeGenerationStore((state) => state.prompt);
|
||||
const jsonPrompt = useAIThemeGenerationStore((state) => state.jsonPrompt);
|
||||
const setPrompt = useAIThemeGenerationStore((state) => state.setPrompt);
|
||||
const setJsonPrompt = useAIThemeGenerationStore((state) => state.setJsonPrompt);
|
||||
const resetPrompts = useAIThemeGenerationStore((state) => state.resetPrompts);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
jsonPrompt,
|
||||
setPrompt,
|
||||
setJsonPrompt,
|
||||
resetPrompts,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for components that need to know about the last generated theme
|
||||
export function useAIThemeGenerationResult() {
|
||||
const lastGeneratedTheme = useAIThemeGenerationStore((state) => state.lastGeneratedTheme);
|
||||
const hasPrompted = useAIThemeGenerationStore((state) => state.hasPrompted);
|
||||
|
||||
return {
|
||||
lastGeneratedTheme,
|
||||
hasPrompted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
const TABS = ["colors", "typography", "other", "ai"] as const;
|
||||
const DEFAULT_TAB = TABS[0];
|
||||
export type ControlTab = (typeof TABS)[number];
|
||||
|
||||
export const useControlsTabFromUrl = () => {
|
||||
const [tab, setTab] = useQueryState("tab", {
|
||||
defaultValue: DEFAULT_TAB,
|
||||
parse: (value: string) => {
|
||||
// Synchronously validate the tab value, and if it's invalid, fallback to the default tab
|
||||
if (!TABS.includes(value as ControlTab)) {
|
||||
console.warn(`Invalid tab value: ${value}. Falling back to default.`);
|
||||
return DEFAULT_TAB;
|
||||
}
|
||||
return value as ControlTab;
|
||||
},
|
||||
});
|
||||
|
||||
const handleSetTab = (tab: ControlTab) => {
|
||||
// If the incoming tab is invalid, fallback to the default tab
|
||||
if (!TABS.includes(tab)) {
|
||||
console.warn(`Invalid tab value: ${tab}. Falling back to default.`);
|
||||
setTab(DEFAULT_TAB);
|
||||
return;
|
||||
}
|
||||
|
||||
setTab(tab);
|
||||
};
|
||||
|
||||
return { tab, handleSetTab };
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { createContext, ReactNode, useContext, useState } from "react";
|
||||
|
||||
import { AIGenerateDialog } from "@/components/editor/action-bar/components/ai-generate-dialog";
|
||||
import { CodePanelDialog } from "@/components/editor/code-panel-dialog";
|
||||
import CssImportDialog from "@/components/editor/css-import-dialog";
|
||||
import { ShareDialog } from "@/components/editor/share-dialog";
|
||||
@@ -21,7 +20,6 @@ interface DialogActionsContextType {
|
||||
cssImportOpen: boolean;
|
||||
codePanelOpen: boolean;
|
||||
saveDialogOpen: boolean;
|
||||
aiGenerateOpen: boolean;
|
||||
shareDialogOpen: boolean;
|
||||
shareUrl: string;
|
||||
dialogKey: number;
|
||||
@@ -32,14 +30,11 @@ interface DialogActionsContextType {
|
||||
setCssImportOpen: (open: boolean) => void;
|
||||
setCodePanelOpen: (open: boolean) => void;
|
||||
setSaveDialogOpen: (open: boolean) => void;
|
||||
setAiGenerateOpen: (open: boolean) => void;
|
||||
setShareDialogOpen: (open: boolean) => void;
|
||||
|
||||
// Handler functions
|
||||
handleCssImport: (css: string) => void;
|
||||
handleSaveClick: (options?: { shareAfterSave?: boolean }) => void;
|
||||
handleAiGenerateClick: () => void;
|
||||
handleGenerateTheme: (promptText: string, jsonPromptText: string) => Promise<void>;
|
||||
handleShareClick: (id?: string) => Promise<void>;
|
||||
saveTheme: (themeName: string) => Promise<void>;
|
||||
}
|
||||
@@ -49,62 +44,28 @@ function useDialogActionsStore(): DialogActionsContextType {
|
||||
const [codePanelOpen, setCodePanelOpen] = useState(false);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [shareAfterSave, setShareAfterSave] = useState(false);
|
||||
const [aiGenerateOpen, setAiGenerateOpen] = useState(false);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState("");
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
|
||||
const {
|
||||
themeState,
|
||||
setThemeState,
|
||||
applyThemePreset,
|
||||
hasThemeChangedFromCheckpoint,
|
||||
} = useEditorStore();
|
||||
const { themeState, setThemeState, applyThemePreset, hasThemeChangedFromCheckpoint } =
|
||||
useEditorStore();
|
||||
const { getPreset } = useThemePresetStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { openAuthDialog } = useAuthStore();
|
||||
const { createTheme, isCreatingTheme } = useThemeActions();
|
||||
const { generateTheme, loading: aiGenerateLoading } = useAIThemeGeneration();
|
||||
const { loading: aiGenerateLoading } = useAIThemeGeneration();
|
||||
const posthog = usePostHog();
|
||||
|
||||
usePostLoginAction("SAVE_THEME", () => {
|
||||
setSaveDialogOpen(true);
|
||||
});
|
||||
|
||||
usePostLoginAction("AI_GENERATE_FROM_DIALOG", () => {
|
||||
setAiGenerateOpen(true);
|
||||
});
|
||||
|
||||
usePostLoginAction("SAVE_THEME_FOR_SHARE", () => {
|
||||
setSaveDialogOpen(true);
|
||||
setShareAfterSave(true);
|
||||
});
|
||||
|
||||
const handleGenerateTheme = async (promptText: string, jsonPromptText: string) => {
|
||||
if (!promptText.trim()) return;
|
||||
await generateTheme({
|
||||
prompt: promptText,
|
||||
jsonPrompt: jsonPromptText,
|
||||
onSuccess: () => {
|
||||
setAiGenerateOpen(false);
|
||||
setDialogKey((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
|
||||
posthog.capture("AI_GENERATE_THEME", {
|
||||
prompt_text: promptText,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAiGenerateClick = () => {
|
||||
if (!session) {
|
||||
openAuthDialog("signin", "AI_GENERATE_FROM_DIALOG");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiGenerateOpen(true);
|
||||
};
|
||||
|
||||
const handleCssImport = (css: string) => {
|
||||
const { lightColors, darkColors } = parseCssInput(css);
|
||||
const styles = {
|
||||
@@ -198,7 +159,6 @@ function useDialogActionsStore(): DialogActionsContextType {
|
||||
cssImportOpen,
|
||||
codePanelOpen,
|
||||
saveDialogOpen,
|
||||
aiGenerateOpen,
|
||||
shareDialogOpen,
|
||||
shareUrl,
|
||||
dialogKey,
|
||||
@@ -209,14 +169,11 @@ function useDialogActionsStore(): DialogActionsContextType {
|
||||
setCssImportOpen,
|
||||
setCodePanelOpen,
|
||||
setSaveDialogOpen,
|
||||
setAiGenerateOpen,
|
||||
setShareDialogOpen,
|
||||
|
||||
// Handler functions
|
||||
handleCssImport,
|
||||
handleSaveClick,
|
||||
handleAiGenerateClick,
|
||||
handleGenerateTheme,
|
||||
handleShareClick,
|
||||
saveTheme,
|
||||
};
|
||||
@@ -251,13 +208,6 @@ export function DialogActionsProvider({ children }: { children: ReactNode }) {
|
||||
onSave={store.saveTheme}
|
||||
isSaving={store.isCreatingTheme}
|
||||
/>
|
||||
<AIGenerateDialog
|
||||
key={store.dialogKey}
|
||||
open={store.aiGenerateOpen}
|
||||
onOpenChange={store.setAiGenerateOpen}
|
||||
loading={store.aiGenerateLoading}
|
||||
onGenerate={store.handleGenerateTheme}
|
||||
/>
|
||||
<ShareDialog
|
||||
open={store.shareDialogOpen}
|
||||
onOpenChange={store.setShareDialogOpen}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useEffect } from "react";
|
||||
|
||||
export type PostLoginActionType =
|
||||
| "SAVE_THEME"
|
||||
| "AI_GENERATE_FROM_DIALOG"
|
||||
| "AI_GENERATE_FROM_PAGE"
|
||||
| "AI_GENERATE_FROM_CHAT"
|
||||
| "SAVE_THEME_FOR_SHARE";
|
||||
|
||||
@@ -23,7 +23,7 @@ export type StoredPostLoginAction = PostLoginActionPayload | null;
|
||||
|
||||
type PostLoginHandler<T = any> = (data?: T) => void | Promise<void>;
|
||||
|
||||
const handlers: Map<PostLoginActionType, PostLoginHandler[]> = new Map();
|
||||
const handlers: Map<PostLoginActionType, PostLoginHandler> = new Map();
|
||||
const readyActions: Set<PostLoginActionType> = new Set();
|
||||
let pendingAction: StoredPostLoginAction = null;
|
||||
|
||||
@@ -34,12 +34,7 @@ export function usePostLoginAction<T = any>(
|
||||
useEffect(() => {
|
||||
if (!actionType) return;
|
||||
|
||||
if (!handlers.has(actionType)) {
|
||||
handlers.set(actionType, []);
|
||||
}
|
||||
handlers.get(actionType)!.push(handler as PostLoginHandler); // Cast for simplicity, ensure T matches
|
||||
|
||||
// Signal this action type is ready to be executed if needed
|
||||
handlers.set(actionType, handler as PostLoginHandler);
|
||||
readyActions.add(actionType);
|
||||
|
||||
// If there's a pending action that matches this one, execute it
|
||||
@@ -49,17 +44,10 @@ export function usePostLoginAction<T = any>(
|
||||
}
|
||||
|
||||
return () => {
|
||||
const actionHandlers = handlers.get(actionType);
|
||||
if (actionHandlers) {
|
||||
const index = actionHandlers.indexOf(handler as PostLoginHandler);
|
||||
if (index > -1) {
|
||||
actionHandlers.splice(index, 1);
|
||||
}
|
||||
|
||||
// If no more handlers for this action, remove from ready set
|
||||
if (actionHandlers.length === 0) {
|
||||
readyActions.delete(actionType);
|
||||
}
|
||||
const handler = handlers.get(actionType);
|
||||
if (handler) {
|
||||
handlers.delete(actionType);
|
||||
readyActions.delete(actionType);
|
||||
}
|
||||
};
|
||||
}, [actionType, handler]);
|
||||
@@ -71,12 +59,10 @@ async function executePostLoginActionInternal(actionPayload: StoredPostLoginActi
|
||||
|
||||
const actionType = actionPayload.type;
|
||||
const actionData = actionPayload.data;
|
||||
const actionHandlers = handlers.get(actionType);
|
||||
const handler = handlers.get(actionType);
|
||||
|
||||
if (actionHandlers && actionHandlers.length > 0) {
|
||||
for (const handler of actionHandlers) {
|
||||
await handler(actionData);
|
||||
}
|
||||
if (handler) {
|
||||
await handler(actionData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-59
@@ -1,18 +1,13 @@
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { defaultThemeState } from "@/config/theme";
|
||||
import { JSONContent } from "@tiptap/react";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { AIPromptData } from "@/types/ai";
|
||||
import { Theme } from "@/types/theme";
|
||||
interface GenerateThemeOptions {
|
||||
onSuccess?: (themeStyles: Theme["styles"]) => void;
|
||||
onError?: (error: Error) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
import { buildPromptForAPI } from "@/utils/ai-prompt";
|
||||
|
||||
/**
|
||||
* Generate a theme with AI using a text prompt
|
||||
*/
|
||||
export async function generateThemeWithAI(prompt: string, options?: GenerateThemeOptions) {
|
||||
export async function generateThemeWithAI(prompt: string, options?: { signal?: AbortSignal }) {
|
||||
if (!prompt.trim()) return null;
|
||||
|
||||
try {
|
||||
@@ -26,42 +21,21 @@ export async function generateThemeWithAI(prompt: string, options?: GenerateThem
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate theme");
|
||||
const errorBody = await response.text();
|
||||
const errorMessage = errorBody || "Failed to generate theme. Please try again.";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const themeStyles = await response.json();
|
||||
applyGeneratedTheme(themeStyles);
|
||||
const result = await response.json();
|
||||
applyGeneratedTheme(result.theme);
|
||||
|
||||
options?.onSuccess?.(themeStyles);
|
||||
return themeStyles;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("AI theme generation error:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
options?.onError?.(error);
|
||||
} else {
|
||||
options?.onError?.(new Error("Unknown error occurred"));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a theme with AI using a structured prompt
|
||||
* with references to existing themes/presets
|
||||
*/
|
||||
export async function generateThemeWithReferences(
|
||||
textPrompt: string,
|
||||
jsonPrompt: string,
|
||||
options?: GenerateThemeOptions
|
||||
) {
|
||||
if (!textPrompt.trim()) return null;
|
||||
|
||||
const transformedPrompt = transformPrompt(textPrompt, jsonPrompt);
|
||||
return generateThemeWithAI(transformedPrompt, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a generated theme to the editor state
|
||||
*/
|
||||
@@ -94,27 +68,6 @@ export function applyGeneratedTheme(themeStyles: Theme["styles"]) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a prompt to include references to other themes
|
||||
*/
|
||||
function transformPrompt(prompt: string, jsonPrompt: string) {
|
||||
const parsedJsonPrompt = JSON.parse(jsonPrompt) as JSONContent;
|
||||
const mentions = parsedJsonPrompt.content?.[0]?.content?.filter(
|
||||
(item) => item.type === "mention"
|
||||
);
|
||||
|
||||
const getMentionContent = (id: string) => {
|
||||
if (id === "editor:current-changes") {
|
||||
return useEditorStore.getState().themeState.styles;
|
||||
}
|
||||
|
||||
return useThemePresetStore.getState().getPreset(id)?.styles;
|
||||
};
|
||||
|
||||
const mentionReferences = mentions?.map(
|
||||
(mention) => `@${mention.attrs?.label} =
|
||||
${JSON.stringify(getMentionContent(mention.attrs?.id))}`
|
||||
);
|
||||
|
||||
return prompt + "\n\n" + (mentionReferences?.join("\n") || "");
|
||||
export function buildPrompt(promptData: AIPromptData) {
|
||||
return buildPromptForAPI(promptData);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const AI_PROMPT_CHARACTER_LIMIT = 500;
|
||||
@@ -1,6 +1,7 @@
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export function initPostHog() {
|
||||
if (process.env.NODE_ENV === "development") return;
|
||||
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
if (posthogKey) {
|
||||
posthog.init(posthogKey, {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-character-count": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.11.9",
|
||||
"@tiptap/extension-placeholder": "^2.11.9",
|
||||
"@tiptap/react": "^2.11.9",
|
||||
|
||||
Generated
+14
@@ -122,6 +122,9 @@ importers:
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@tiptap/extension-character-count':
|
||||
specifier: ^2.12.0
|
||||
version: 2.12.0(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)
|
||||
'@tiptap/extension-mention':
|
||||
specifier: ^2.11.9
|
||||
version: 2.11.9(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)(@tiptap/suggestion@2.11.9(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9))
|
||||
@@ -2643,6 +2646,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-character-count@2.12.0':
|
||||
resolution: {integrity: sha512-UCtcDyDj1nhKHc7AlOxR2PkFpRs7pWV8+/pfyWr471ECJHpJ2eDR3dyaH8uzkNiakHgqcwDca9lRu9lwHDfWcw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/extension-code-block@2.11.9':
|
||||
resolution: {integrity: sha512-brwvt/SdP65DpchPv5rkhjEjjIIgE1+9ySw8kCTiyXWUrmZA0kK/iwp5zPpHfsoWT8Sa9+fh2uraVZGOF9Dcqw==}
|
||||
peerDependencies:
|
||||
@@ -8443,6 +8452,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.9(@tiptap/pm@2.11.9)
|
||||
|
||||
'@tiptap/extension-character-count@2.12.0(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.9(@tiptap/pm@2.11.9)
|
||||
'@tiptap/pm': 2.11.9
|
||||
|
||||
'@tiptap/extension-code-block@2.11.9(@tiptap/core@2.11.9(@tiptap/pm@2.11.9))(@tiptap/pm@2.11.9)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.9(@tiptap/pm@2.11.9)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,94 @@
|
||||
import { AssistantMessage, ChatMessage, UserMessage } from "@/types/ai";
|
||||
import { ThemeStyles } from "@/types/theme";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface AIChatStore {
|
||||
messages: ChatMessage[];
|
||||
getDefaultMessage: () => ChatMessage;
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
addUserMessage: (message: UserMessage) => void;
|
||||
addAssistantMessage: (message: AssistantMessage) => void;
|
||||
clearMessages: () => void;
|
||||
resetMessagesUpToIndex: (index: number) => void;
|
||||
}
|
||||
|
||||
export const useAIChatStore = create<AIChatStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
messages: [],
|
||||
getDefaultMessage: () => {
|
||||
const defaultMessage: ChatMessage = {
|
||||
id: "default-message",
|
||||
content: "How can I help you theme?",
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return defaultMessage;
|
||||
},
|
||||
addMessage: (message: ChatMessage) => {
|
||||
set((state) => ({ messages: [...state.messages, message] }));
|
||||
},
|
||||
addUserMessage: (message: UserMessage) => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
promptData: message.promptData,
|
||||
role: "user",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({ messages: [...state.messages, userMessage] }));
|
||||
},
|
||||
addAssistantMessage: (message: AssistantMessage) => {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
content: message.content,
|
||||
themeStyles: message.themeStyles,
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({ messages: [...state.messages, assistantMessage] }));
|
||||
},
|
||||
clearMessages: () => {
|
||||
set({ messages: [] });
|
||||
},
|
||||
resetMessagesUpToIndex: (index: number) => {
|
||||
set((state) => ({ messages: state.messages.slice(0, index) }));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "ai-chat-storage",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const getUserMessagesCount = (messages: ChatMessage[]) => {
|
||||
return messages.filter((message) => message.role === "user").length;
|
||||
};
|
||||
|
||||
export const getUserMessages = (messages: ChatMessage[]) => {
|
||||
return messages.filter((message) => message.role === "user");
|
||||
};
|
||||
|
||||
export const getAssistantMessages = (messages: ChatMessage[]) => {
|
||||
return messages.filter((message) => message.role === "assistant");
|
||||
};
|
||||
|
||||
export const getLastUserMessage = (messages: ChatMessage[]) => {
|
||||
const userMessages = getUserMessages(messages);
|
||||
return userMessages[userMessages.length - 1];
|
||||
};
|
||||
|
||||
export const getLastGeneratedThemeStyles = (messages: ChatMessage[]): ThemeStyles | null => {
|
||||
const assistantMessages = getAssistantMessages(messages);
|
||||
// recursively find the last theme styles message starting from the last message
|
||||
for (let i = assistantMessages.length - 1; i >= 0; i--) {
|
||||
const message = assistantMessages[i];
|
||||
if (message.themeStyles) {
|
||||
return message.themeStyles;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -1,60 +1,28 @@
|
||||
import { generateThemeWithReferences } from "@/lib/ai-theme-generator";
|
||||
import { generateThemeWithAI } from "@/lib/ai-theme-generator";
|
||||
import { ThemeStyles } from "@/types/theme";
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface GenerateThemeOptions {
|
||||
prompt?: string;
|
||||
jsonPrompt?: string;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface AIThemeGenerationStore {
|
||||
loading: boolean;
|
||||
prompt: string;
|
||||
jsonPrompt: string;
|
||||
hasPrompted: boolean;
|
||||
lastGeneratedTheme: any | null;
|
||||
abortController: AbortController | null;
|
||||
|
||||
// State setters
|
||||
setLoading: (loading: boolean) => void;
|
||||
setPrompt: (prompt: string) => void;
|
||||
setJsonPrompt: (jsonPrompt: string) => void;
|
||||
|
||||
// Actions
|
||||
generateTheme: (options?: GenerateThemeOptions) => Promise<any>;
|
||||
// generateTheme now only takes prompt. Callbacks are removed.
|
||||
generateTheme: (prompt: string) => Promise<{ text: string; theme: ThemeStyles }>;
|
||||
cancelThemeGeneration: () => void;
|
||||
|
||||
// Utility methods
|
||||
resetPrompts: () => void;
|
||||
resetState: () => void;
|
||||
updateLastGeneratedTheme: (theme: any) => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
loading: false,
|
||||
prompt: "",
|
||||
jsonPrompt: "",
|
||||
hasPrompted: false,
|
||||
lastGeneratedTheme: null,
|
||||
abortController: null,
|
||||
};
|
||||
|
||||
export const useAIThemeGenerationStore = create<AIThemeGenerationStore>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// State setters
|
||||
setLoading: (loading: boolean) => set({ loading }),
|
||||
setPrompt: (prompt: string) => set({ prompt }),
|
||||
setJsonPrompt: (jsonPrompt: string) => set({ jsonPrompt }),
|
||||
|
||||
// Utility methods
|
||||
resetPrompts: () => set({ prompt: "", jsonPrompt: "" }),
|
||||
resetState: () => set(initialState),
|
||||
updateLastGeneratedTheme: (theme: any) => set({ lastGeneratedTheme: theme }),
|
||||
|
||||
// Cancel ongoing theme generation
|
||||
cancelThemeGeneration: () => {
|
||||
const { abortController } = get();
|
||||
if (abortController) {
|
||||
@@ -63,39 +31,31 @@ export const useAIThemeGenerationStore = create<AIThemeGenerationStore>()((set,
|
||||
}
|
||||
},
|
||||
|
||||
generateTheme: async (options?: GenerateThemeOptions) => {
|
||||
generateTheme: async (prompt: string) => {
|
||||
const state = get();
|
||||
const finalPrompt = options?.prompt || state.prompt;
|
||||
const finalJsonPrompt = options?.jsonPrompt || state.jsonPrompt;
|
||||
if (!prompt.trim()) return; // Or throw new Error("Prompt cannot be empty");
|
||||
|
||||
if (!finalPrompt.trim()) return;
|
||||
|
||||
// Cancel any ongoing requests
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
}
|
||||
|
||||
// Create new AbortController for this request
|
||||
const abortController = new AbortController();
|
||||
set({ loading: true, abortController });
|
||||
|
||||
try {
|
||||
const themeStyles = await generateThemeWithReferences(finalPrompt, finalJsonPrompt, {
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
const response = await generateThemeWithAI(prompt, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
set({
|
||||
hasPrompted: true,
|
||||
lastGeneratedTheme: themeStyles,
|
||||
});
|
||||
|
||||
return themeStyles;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error("Error in store generateTheme:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
set({ loading: false, abortController: null });
|
||||
if (get().abortController === abortController) {
|
||||
set({ loading: false, abortController: null });
|
||||
} else {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -184,7 +184,10 @@ export const useEditorStore = create<EditorStore>()(
|
||||
const newFuture = [newFutureEntry, ...future];
|
||||
|
||||
set({
|
||||
themeState: lastHistoryEntry.state,
|
||||
themeState: {
|
||||
...lastHistoryEntry.state,
|
||||
currentMode: currentThemeState.currentMode,
|
||||
},
|
||||
themeCheckpoint: lastHistoryEntry.state,
|
||||
history: newHistory,
|
||||
future: newFuture,
|
||||
@@ -209,7 +212,10 @@ export const useEditorStore = create<EditorStore>()(
|
||||
}
|
||||
|
||||
set({
|
||||
themeState: firstFutureEntry.state,
|
||||
themeState: {
|
||||
...firstFutureEntry.state,
|
||||
currentMode: currentThemeState.currentMode,
|
||||
},
|
||||
themeCheckpoint: firstFutureEntry.state,
|
||||
history: updatedHistory,
|
||||
future: newFuture,
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { type ThemeStyles, type ThemeStyleProps } from "./theme";
|
||||
|
||||
export type MentionReference = {
|
||||
id: string;
|
||||
label: string;
|
||||
themeData: {
|
||||
light: Partial<ThemeStyleProps>;
|
||||
dark: Partial<ThemeStyleProps>;
|
||||
};
|
||||
};
|
||||
|
||||
export type AIPromptData = {
|
||||
content: string;
|
||||
mentions: MentionReference[];
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
timestamp: number;
|
||||
promptData?: AIPromptData;
|
||||
content?: string;
|
||||
themeStyles?: ThemeStyles;
|
||||
};
|
||||
|
||||
export type UserMessage = {
|
||||
promptData: AIPromptData;
|
||||
};
|
||||
|
||||
export type AssistantMessage = {
|
||||
content: string;
|
||||
themeStyles?: ThemeStyles;
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AIPromptData, MentionReference } from "@/types/ai";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { getLastGeneratedThemeStyles, useAIChatStore } from "@/store/ai-chat-store";
|
||||
|
||||
export const getTextContent = (promptData: AIPromptData | null) => {
|
||||
if (!promptData) return "";
|
||||
return promptData.content;
|
||||
};
|
||||
|
||||
export const mentionsCount = (promptData: AIPromptData | null) => {
|
||||
if (!promptData) return 0;
|
||||
return promptData.mentions.length;
|
||||
};
|
||||
|
||||
export const buildPromptForAPI = (promptData: AIPromptData) => {
|
||||
const mentionReferences = promptData.mentions.map(
|
||||
(mention) => `@${mention.label} =
|
||||
${JSON.stringify(mention.themeData)}`
|
||||
);
|
||||
|
||||
return `${promptData.content}\n\n${mentionReferences.join("\n")}`;
|
||||
};
|
||||
|
||||
export const buildAIPromptRender = (promptData: AIPromptData): React.ReactNode => {
|
||||
// Create a regex that matches all possible mention patterns from the actual mentions
|
||||
const mentionPatterns = promptData.mentions.map(
|
||||
(m) => `@${m.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
|
||||
);
|
||||
const mentionRegex = new RegExp(`(${mentionPatterns.join("|")})`, "g");
|
||||
|
||||
const parts = promptData.content.split(mentionRegex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
const mention = promptData.mentions.find((m) => `@${m.label}` === part);
|
||||
if (mention) {
|
||||
return (
|
||||
<span key={index} className="mention">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
export function attachCurrentThemeMention(promptData: AIPromptData): AIPromptData {
|
||||
const currentThemeData = useEditorStore.getState().themeState.styles;
|
||||
|
||||
const mentionReference: MentionReference = {
|
||||
id: "editor:current-changes",
|
||||
label: "Current Theme",
|
||||
themeData: currentThemeData,
|
||||
};
|
||||
|
||||
return {
|
||||
content: promptData.content,
|
||||
mentions: [...promptData.mentions, mentionReference],
|
||||
};
|
||||
}
|
||||
|
||||
export function attachLastGeneratedThemeMention(promptData: AIPromptData): AIPromptData {
|
||||
const lastGeneratedThemeStyles = getLastGeneratedThemeStyles(useAIChatStore.getState().messages);
|
||||
if (!lastGeneratedThemeStyles) return promptData;
|
||||
|
||||
const mentionReference: MentionReference = {
|
||||
id: "ai:last-generated-theme",
|
||||
label: "Last Generated Theme",
|
||||
themeData: lastGeneratedThemeStyles,
|
||||
};
|
||||
|
||||
return {
|
||||
content: promptData.content,
|
||||
mentions: [...promptData.mentions, mentionReference],
|
||||
};
|
||||
}
|
||||
|
||||
export function createCurrentThemePrompt({ prompt }: { prompt: string }): AIPromptData {
|
||||
const currentThemeData = useEditorStore.getState().themeState.styles;
|
||||
|
||||
const mentionReference: MentionReference = {
|
||||
id: "editor:current-changes",
|
||||
label: "Current Theme",
|
||||
themeData: currentThemeData,
|
||||
};
|
||||
|
||||
return {
|
||||
content: `Make the following changes to the @Current Theme:\n${prompt}`,
|
||||
mentions: [mentionReference],
|
||||
};
|
||||
}
|
||||
|
||||
export function mentionsCurrentTheme(promptData: AIPromptData): boolean {
|
||||
return promptData.mentions.some((mention) => mention.id === "editor:current-changes");
|
||||
}
|
||||
|
||||
export function createPromptDataFromMentions(content: string, mentionIds: string[]): AIPromptData {
|
||||
const mentions: MentionReference[] = mentionIds.map((id) => {
|
||||
if (id === "editor:current-changes") {
|
||||
return {
|
||||
id,
|
||||
label: "Current Theme",
|
||||
themeData: useEditorStore.getState().themeState.styles,
|
||||
};
|
||||
}
|
||||
|
||||
const preset = useThemePresetStore.getState().getPreset(id);
|
||||
if (!preset) {
|
||||
throw new Error(`Theme preset not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label: preset.label || id,
|
||||
themeData: preset.styles,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
mentions,
|
||||
};
|
||||
}
|
||||
+3
-40
@@ -1,54 +1,17 @@
|
||||
import { JSONContent } from "@tiptap/react";
|
||||
|
||||
export const PROMPTS = {
|
||||
flatDesign: {
|
||||
label: "Flat Design",
|
||||
prompt:
|
||||
"I want a flat design. Make the 'background', 'card', 'popover', and 'sidebar' tokens use the same color value, preferably the same as the 'background' color. Remove shadows completely, for example by setting 'shadow-opacity' to '0%' or 'shadow-blur' and 'shadow-spread' to '0px'. Default 'border' styles are okay.",
|
||||
"I want a flat design. Make the surface color tokens use the same color value, preferably the same as the 'background' color. Remove shadows completely. Default 'border' styles are okay.",
|
||||
},
|
||||
minimalStyle: {
|
||||
label: "Minimal Style",
|
||||
prompt:
|
||||
"Generate a minimalist theme palette. Use a very limited set of harmonious colors. All surfaces (background, card, popover, sidebar) should use subtle variations of the same base color, with enough contrast to distinguish them when they are next to each other. For brand colors (primary, secondary, muted, accent, ring) keep original color palette. Minimize borders and shadows. For shadows, you can set 'shadow-opacity' to 0% or near 0% or make them barely visible. For borders, use a subtle grayscale color. Typography should be clean, modern, and easy to read, with no decorative fonts. The overall look should be calm, simple, and highly functional.",
|
||||
"Generate a minimalist theme palette. All surfaces color tokens should use subtle variations of the same base color, with enough contrast to distinguish them when they are next to each other. For brand colors, keep original color palette. Minimize borders and shadows. For borders, use a subtle grayscale color. Typography should be clean, modern, and easy to read.",
|
||||
},
|
||||
brutalist: {
|
||||
label: "Make it Brutalist",
|
||||
prompt:
|
||||
"Make it brutalist style. Set 'radius' to '0px'. The 'border' color should strongly contrast with the 'background' color. For shadows, use a 'shadow-color' that also contrasts sharply with the 'background', set 'shadow-blur' to '0px', 'shadow-opacity' to '100%', and use 'shadow-offset-x', 'shadow-offset-y', and 'shadow-spread' to create a hard, offset shadow effect, but try not to exceed 4px. For brand colors (primary, secondary, muted, accent, ring) keep original color palette, and make colors slightly more vibrant, for that you can increase saturation, but keep hue-consistent.",
|
||||
"Make it brutalist style. Set 'radius' to '0px'. The 'border' color should strongly contrast with the 'background' color. For shadows, use a 'shadow-color' that also contrasts sharply with the 'background', set 'shadow-blur' to '0px', 'shadow-opacity' to '100%', and use 'shadow-offset', and 'shadow-spread' to create a hard offset shadow effect, do not exceed 4px. Keep original color palette, and make colors slightly more vibrant.",
|
||||
},
|
||||
};
|
||||
|
||||
export function createCurrentThemePromptJson({ prompt }: { prompt: string }): JSONContent {
|
||||
// Define the mention node structure
|
||||
const mentionNode: JSONContent = {
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: "editor:current-changes",
|
||||
label: "Current Theme",
|
||||
},
|
||||
};
|
||||
|
||||
// Define the text parts
|
||||
const textPart1: JSONContent = {
|
||||
type: "text",
|
||||
text: "Make the following changes based on ",
|
||||
};
|
||||
const textPart2: JSONContent = {
|
||||
type: "text",
|
||||
text: prompt,
|
||||
};
|
||||
|
||||
// Combine into a paragraph
|
||||
const paragraphNode: JSONContent = {
|
||||
type: "paragraph",
|
||||
content: [textPart1, mentionNode, textPart2],
|
||||
};
|
||||
|
||||
// Combine into the top-level document
|
||||
const docNode: JSONContent = {
|
||||
type: "doc",
|
||||
content: [paragraphNode],
|
||||
};
|
||||
|
||||
return docNode;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user