mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-30 18:10:28 +08:00
Feature/ai (#57)
* ai setup * working ai theme with rich editor * update action bar ui * style chnages * submit on enter * remove imports * fix: Merge ai theme with default * refactor * put AI behind auth and fix post login action * feat: Add ratelimiting using kv and use groq * fix build errors * lazy load editor
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { createGroq } from "@ai-sdk/groq";
|
||||
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";
|
||||
|
||||
const requestSchema = z.object({
|
||||
prompt: z.string().min(1),
|
||||
});
|
||||
|
||||
// Create a new schema based on themeStylePropsSchema excluding 'spacing'
|
||||
const themeStylePropsWithoutSpacing = themeStylePropsSchema.omit({
|
||||
spacing: true,
|
||||
});
|
||||
|
||||
// Define the main theme schema using the modified props schema
|
||||
const themeSchemaWithoutSpacing = z.object({
|
||||
light: themeStylePropsWithoutSpacing,
|
||||
dark: themeStylePropsWithoutSpacing,
|
||||
});
|
||||
|
||||
// Create Rate limit - 5 requests per 60 seconds
|
||||
const ratelimit = new Ratelimit({
|
||||
redis: kv,
|
||||
limiter: Ratelimit.fixedWindow(5, "60s"),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Apply rate limiting based on the request IP
|
||||
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await ratelimit.limit(ip);
|
||||
|
||||
// Block the request if rate limit exceeded
|
||||
if (!success) {
|
||||
return new Response("Rate limit exceeded. Please try again later.", {
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { prompt } = requestSchema.parse(body);
|
||||
|
||||
const groq = createGroq({
|
||||
apiKey: process.env.GROQ_API_KEY,
|
||||
});
|
||||
|
||||
const model = groq("llama-3.3-70b-versatile");
|
||||
|
||||
const { object: theme } = await generateObject({
|
||||
model,
|
||||
schema: themeSchemaWithoutSpacing,
|
||||
system: `You are an AI generating Shadcn UI color themes.
|
||||
Input: User description or existing theme tokens.
|
||||
Format: Use Hex values (#000000) exclusively for colors.
|
||||
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.`,
|
||||
prompt: `Generate Shadcn theme. Input: ${prompt}`,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(theme), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// Consider more specific error handling based on AI SDK errors if needed
|
||||
return new Response("Error generating theme", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -114,3 +114,21 @@ body {
|
||||
/* Apply the reveal animation */
|
||||
animation: reveal 0.4s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Mention styles */
|
||||
.mention {
|
||||
@apply bg-primary/10 text-primary px-1 rounded-md;
|
||||
}
|
||||
|
||||
@keyframes text {
|
||||
from {
|
||||
background-position: 0% center;
|
||||
}
|
||||
to {
|
||||
background-position: -200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-text {
|
||||
animation: text 3s linear infinite;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function AuthDialogWrapper() {
|
||||
}
|
||||
|
||||
if (session && postLoginAction) {
|
||||
// Execute action immediately - the system will now handle waiting for handlers
|
||||
executePostLoginAction(postLoginAction);
|
||||
clearPostLoginAction();
|
||||
}
|
||||
|
||||
@@ -13,21 +13,52 @@ import { usePostLoginAction } from "@/hooks/use-post-login-action";
|
||||
import { useThemeActions } from "@/hooks/use-theme-actions";
|
||||
import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { AIGenerateDialog } from "@/components/editor/action-bar/components/ai-generate-dialog";
|
||||
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
|
||||
|
||||
export function ActionBar() {
|
||||
const { themeState, setThemeState, applyThemePreset } = useEditorStore();
|
||||
const [cssImportOpen, setCssImportOpen] = useState(false);
|
||||
const [codePanelOpen, setCodePanelOpen] = useState(false);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [aiGenerateOpen, setAiGenerateOpen] = useState(false);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const { data: session } = authClient.useSession();
|
||||
const { openAuthDialog } = useAuthStore();
|
||||
const { createTheme, isCreatingTheme } = useThemeActions();
|
||||
const posthog = usePostHog();
|
||||
const { generateTheme, loading: aiGenerateLoading } = useAIThemeGeneration({
|
||||
onSuccess: () => {
|
||||
setAiGenerateOpen(false);
|
||||
setDialogKey((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
|
||||
usePostLoginAction("SAVE_THEME", () => {
|
||||
setSaveDialogOpen(true);
|
||||
});
|
||||
|
||||
usePostLoginAction("AI_GENERATE", () => {
|
||||
setAiGenerateOpen(true);
|
||||
});
|
||||
|
||||
const handleGenerateTheme = async (
|
||||
promptText: string,
|
||||
jsonPromptText: string
|
||||
) => {
|
||||
if (!promptText.trim()) return;
|
||||
await generateTheme(promptText, jsonPromptText);
|
||||
};
|
||||
|
||||
const handleAiGenerateClick = () => {
|
||||
if (!session) {
|
||||
openAuthDialog("signin", "AI_GENERATE");
|
||||
return;
|
||||
}
|
||||
|
||||
setAiGenerateOpen(true);
|
||||
};
|
||||
|
||||
const handleCssImport = (css: string) => {
|
||||
const { lightColors, darkColors } = parseCssInput(css);
|
||||
const styles = {
|
||||
@@ -88,6 +119,7 @@ export function ActionBar() {
|
||||
onImportClick={() => setCssImportOpen(true)}
|
||||
onCodeClick={() => setCodePanelOpen(true)}
|
||||
onSaveClick={handleSaveClick}
|
||||
onAiGenerateClick={handleAiGenerateClick}
|
||||
isSaving={isCreatingTheme}
|
||||
/>
|
||||
</div>
|
||||
@@ -108,6 +140,13 @@ export function ActionBar() {
|
||||
onSave={saveTheme}
|
||||
isSaving={isCreatingTheme}
|
||||
/>
|
||||
<AIGenerateDialog
|
||||
key={dialogKey}
|
||||
open={aiGenerateOpen}
|
||||
onOpenChange={setAiGenerateOpen}
|
||||
loading={aiGenerateLoading}
|
||||
onGenerate={handleGenerateTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,17 @@ import { ImportButton } from "./import-button";
|
||||
import { ResetButton } from "./reset-button";
|
||||
import { SaveButton } from "./save-button";
|
||||
import { CodeButton } from "./code-button";
|
||||
import ContrastChecker from "@/components/editor/contrast-checker";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { EditButton } from "./edit-button";
|
||||
import { MoreOptions } from "./more-options";
|
||||
import { AIGenerateButton } from "./ai-generate-button";
|
||||
|
||||
interface ActionBarButtonsProps {
|
||||
onImportClick: () => void;
|
||||
onCodeClick: () => void;
|
||||
onSaveClick: () => void;
|
||||
onAiGenerateClick: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export function ActionBarButtons({
|
||||
onImportClick,
|
||||
onCodeClick,
|
||||
onSaveClick,
|
||||
onAiGenerateClick,
|
||||
isSaving,
|
||||
}: ActionBarButtonsProps) {
|
||||
const { themeState, restoreThemeCheckpoint, hasThemeChangedFromCheckpoint } =
|
||||
@@ -36,14 +38,12 @@ export function ActionBarButtons({
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<ContrastChecker
|
||||
currentStyles={themeState.styles[themeState.currentMode]}
|
||||
/>
|
||||
<ImportButton onImportClick={onImportClick} />
|
||||
<ResetButton
|
||||
onReset={restoreThemeCheckpoint}
|
||||
isDisabled={!hasThemeChangedFromCheckpoint()}
|
||||
/>
|
||||
<ImportButton onImportClick={onImportClick} />
|
||||
<AIGenerateButton onClick={onAiGenerateClick} />
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
{showEditButton && <EditButton themeId={themeState.preset as string} />}
|
||||
<SaveButton onSaveClick={onSaveClick} isSaving={isSaving} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface AIGenerateButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AIGenerateButton({ onClick }: AIGenerateButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
<span className="text-sm hidden md:block animate-text bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[200%_auto] bg-clip-text text-transparent">
|
||||
Generate
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Generate theme with AI</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} 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 overflow-hidden rounded-lg border shadow-lg">
|
||||
<DialogHeader className="px-6 pt-6 mb-1 w-full">
|
||||
<div className="text-center text-2xl font-bold">
|
||||
How can I help you theme?
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<div className="bg-muted/40 rounded-lg p-1">
|
||||
<Suspense fallback={<Loading className="min-h-[80px]" />}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,12 @@ 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";
|
||||
|
||||
export function MoreOptions() {
|
||||
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
||||
const { themeState } = useEditorStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,6 +29,11 @@ export function MoreOptions() {
|
||||
<McpIcon className="h-4 w-4" />
|
||||
MCP
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<ContrastChecker
|
||||
currentStyles={themeState.styles[themeState.currentMode]}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -188,20 +188,11 @@ const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
>
|
||||
<Contrast className="h-4 w-4" />
|
||||
<span className="text-sm hidden md:block">Contrast</span>
|
||||
</Button>
|
||||
<TooltipContent>Check contrast accessibility</TooltipContent>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="px-2 w-full justify-start">
|
||||
<Contrast className="h-4 w-4" />
|
||||
<span className="text-sm hidden md:block">Contrast</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-screen-lg max-h-[90vh]">
|
||||
<DialogHeader className="mb-4">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEditor, EditorContent, JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { suggestion } from "@/components/editor/mention-suggestion"; // We'll create this next
|
||||
|
||||
interface CustomTextareaProps {
|
||||
onContentChange: (textContent: string, jsonContent: JSONContent) => void;
|
||||
onGenerate?: () => void;
|
||||
}
|
||||
|
||||
const CustomTextarea: React.FC<CustomTextareaProps> = ({
|
||||
onContentChange,
|
||||
onGenerate,
|
||||
}) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
suggestion: suggestion,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: "Describe your theme...",
|
||||
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",
|
||||
}),
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50",
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onContentChange(editor.getText(), editor.getJSON());
|
||||
},
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
};
|
||||
|
||||
export default CustomTextarea;
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define the structure of the theme item object
|
||||
interface ThemeItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
items: ThemeItem[]; // Update items type to ThemeItem[]
|
||||
command: (item: { id: string; label: string }) => void; // Update command type if needed, here passing the whole object
|
||||
}
|
||||
|
||||
// Use a type for the ref handle if needed, e.g., { onKeyDown: ... }
|
||||
// Using `any` for now as in the original code
|
||||
export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
(props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// 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]);
|
||||
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{
|
||||
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 cursor-default select-none items-center rounded-sm p-1.5 text-xs outline-none transition-colors focus:bg-accent focus: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={() => selectItem(index)}
|
||||
>
|
||||
{item.label} {/* Display the item's label */}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm p-1.5 text-xs text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
No result
|
||||
</div>
|
||||
) // Use Tailwind classes for the empty state, styled like an item, using cn
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MentionList.displayName = "MentionList";
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import { MentionList } from "@/components/editor/mention-list"; // We'll create this component next
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store"; // Import the theme store
|
||||
|
||||
export const suggestion = {
|
||||
items: ({ query }: { query: string }) => {
|
||||
// Get all presets from the store
|
||||
const allPresets = useThemePresetStore.getState().getAllPresets();
|
||||
|
||||
// Convert presets object to the required array format { id: string, label: string }
|
||||
const themeItems = Object.entries(allPresets).map(([id, preset]) => ({
|
||||
id: id, // Use the preset key as the id
|
||||
label: preset.label, // Use the preset label
|
||||
}));
|
||||
|
||||
// Filter based on the query
|
||||
return themeItems
|
||||
.filter((item) => {
|
||||
const labelWithoutSpaces =
|
||||
item.label?.replace(/\s+/g, "").toLowerCase() || "";
|
||||
const queryWithoutSpaces = query.replace(/\s+/g, "").toLowerCase();
|
||||
return labelWithoutSpaces.includes(queryWithoutSpaces);
|
||||
})
|
||||
.slice(0, 7)
|
||||
.concat({ id: "editor:current-changes", label: "Current Theme" }); // Limit to 5 suggestions
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: any) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props: any) {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props: any) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-expect-error - This is a valid way to access the component's methods
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
popup = null;
|
||||
component = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { generateThemeWithReferences } from "@/lib/ai-theme-generator";
|
||||
|
||||
interface UseAIThemeGenerationProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useAIThemeGeneration(props?: UseAIThemeGenerationProps) {
|
||||
const { onSuccess, onError } = props || {};
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const generateTheme = async (prompt: string, jsonPrompt: string) => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const themeStyles = await generateThemeWithReferences(
|
||||
prompt,
|
||||
jsonPrompt,
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Theme generated",
|
||||
description: "Your AI-generated theme has been applied",
|
||||
});
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to generate theme. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
onError?.(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return themeStyles;
|
||||
} catch (error) {
|
||||
// Error is already handled by the utility function
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
generateTheme,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type PostLoginAction = "SAVE_THEME" | null;
|
||||
export type PostLoginAction = "SAVE_THEME" | "AI_GENERATE" | null;
|
||||
type PostLoginHandler = () => void | Promise<void>;
|
||||
|
||||
const handlers: Map<PostLoginAction, PostLoginHandler[]> = new Map();
|
||||
const readyActions: Set<PostLoginAction> = new Set();
|
||||
let pendingAction: PostLoginAction = null;
|
||||
|
||||
export function usePostLoginAction(
|
||||
action: PostLoginAction,
|
||||
@@ -17,6 +19,15 @@ export function usePostLoginAction(
|
||||
}
|
||||
handlers.get(action)!.push(handler);
|
||||
|
||||
// Signal this action type is ready to be executed if needed
|
||||
readyActions.add(action);
|
||||
|
||||
// If there's a pending action that matches this one, execute it
|
||||
if (pendingAction === action) {
|
||||
executePostLoginActionInternal(action);
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
const actionHandlers = handlers.get(action);
|
||||
if (actionHandlers) {
|
||||
@@ -24,19 +35,37 @@ export function usePostLoginAction(
|
||||
if (index > -1) {
|
||||
actionHandlers.splice(index, 1);
|
||||
}
|
||||
|
||||
// If no more handlers for this action, remove from ready set
|
||||
if (actionHandlers.length === 0) {
|
||||
readyActions.delete(action);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [action, handler]);
|
||||
}
|
||||
|
||||
// Internal function to actually execute handlers
|
||||
async function executePostLoginActionInternal(action: PostLoginAction) {
|
||||
if (!action) return;
|
||||
|
||||
const actionHandlers = handlers.get(action);
|
||||
if (actionHandlers && actionHandlers.length > 0) {
|
||||
for (const handler of actionHandlers) {
|
||||
await handler();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This function should be called when a user successfully logs in
|
||||
export async function executePostLoginAction(action: PostLoginAction) {
|
||||
if (!action) return;
|
||||
|
||||
const actionHandlers = handlers.get(action);
|
||||
if (actionHandlers) {
|
||||
for (const handler of actionHandlers) {
|
||||
await handler();
|
||||
}
|
||||
// If handlers for this action type are ready, execute immediately
|
||||
if (readyActions.has(action)) {
|
||||
await executePostLoginActionInternal(action);
|
||||
} else {
|
||||
// Otherwise, set as pending to be executed when handlers are registered
|
||||
pendingAction = action;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import { defaultThemeState } from "@/config/theme";
|
||||
import { JSONContent } from "@tiptap/react";
|
||||
import { Theme } from "@/types/theme";
|
||||
interface GenerateThemeOptions {
|
||||
onSuccess?: (themeStyles: Theme["styles"]) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a theme with AI using a text prompt
|
||||
*/
|
||||
export async function generateThemeWithAI(
|
||||
prompt: string,
|
||||
options?: GenerateThemeOptions
|
||||
) {
|
||||
if (!prompt.trim()) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate-theme", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate theme");
|
||||
}
|
||||
|
||||
const themeStyles = await response.json();
|
||||
applyGeneratedTheme(themeStyles);
|
||||
|
||||
options?.onSuccess?.(themeStyles);
|
||||
return themeStyles;
|
||||
} 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
|
||||
*/
|
||||
export function applyGeneratedTheme(themeStyles: Theme["styles"]) {
|
||||
const { themeState, setThemeState } = useEditorStore.getState();
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
setThemeState({
|
||||
...themeState,
|
||||
styles: {
|
||||
...themeState.styles,
|
||||
light: { ...defaultThemeState.styles.light, ...themeStyles.light },
|
||||
dark: { ...defaultThemeState.styles.dark, ...themeStyles.dark },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
document.startViewTransition(() => {
|
||||
setThemeState({
|
||||
...themeState,
|
||||
styles: {
|
||||
...themeState.styles,
|
||||
light: {
|
||||
...defaultThemeState.styles.light,
|
||||
...themeStyles.light,
|
||||
},
|
||||
dark: { ...defaultThemeState.styles.dark, ...themeStyles.dark },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") || "");
|
||||
}
|
||||
+15
-1
@@ -10,11 +10,14 @@
|
||||
"generate-theme-registry": "tsx scripts/generate-theme-registry.ts && tsx scripts/generate-registry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/groq": "^1.2.8",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@lexical/react": "^0.31.0",
|
||||
"@neondatabase/serverless": "^1.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.7",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||
@@ -44,6 +47,14 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-mention": "^2.11.9",
|
||||
"@tiptap/extension-placeholder": "^2.11.9",
|
||||
"@tiptap/react": "^2.11.9",
|
||||
"@tiptap/starter-kit": "^2.11.9",
|
||||
"@tiptap/suggestion": "^2.11.9",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@vercel/kv": "^3.0.0",
|
||||
"ai": "^4.3.13",
|
||||
"better-auth": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -56,11 +67,13 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.26",
|
||||
"lexical": "^0.31.0",
|
||||
"lucide-react": "^0.488.0",
|
||||
"motion": "^12.7.3",
|
||||
"next": "15.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.4.3",
|
||||
"openai": "^4.96.2",
|
||||
"posthog-js": "^1.236.1",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.6.7",
|
||||
@@ -73,7 +86,7 @@
|
||||
"sonner": "^2.0.3",
|
||||
"swr": "^2.3.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
@@ -95,6 +108,7 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.19.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1235
-13
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user