open in v0

This commit is contained in:
Sahaj Jain
2025-12-13 22:09:24 +05:30
parent 8f03ac8125
commit 7bbd22371f
12 changed files with 778 additions and 99 deletions
+47 -4
View File
@@ -10,6 +10,7 @@ import {
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/revola";
import { PostLoginActionType } from "@/hooks/use-post-login-action";
import { authClient } from "@/lib/auth-client";
import { Loader2 } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
@@ -20,6 +21,44 @@ interface AuthDialogProps {
onOpenChange: (open: boolean) => void;
initialMode?: "signin" | "signup";
trigger?: React.ReactNode; // Optional trigger element
postLoginActionType?: PostLoginActionType | null;
}
// Get contextual copy based on the post-login action
function getContextualCopy(actionType?: PostLoginActionType | null) {
switch (actionType) {
case "SAVE_THEME":
return {
title: "Sign in to Save",
description: "Sign in to save your theme and access it from anywhere",
};
case "SAVE_THEME_FOR_SHARE":
return {
title: "Sign in to Share",
description: "Sign in to save and share your theme with others",
};
case "SAVE_THEME_FOR_V0":
return {
title: "Sign in to open in v0",
description: "Sign in to save your theme and open it in v0",
};
case "AI_GENERATE_FROM_PAGE":
case "AI_GENERATE_FROM_CHAT":
case "AI_GENERATE_FROM_CHAT_SUGGESTION":
case "AI_GENERATE_EDIT":
case "AI_GENERATE_RETRY":
return {
title: "Sign in for AI",
description: "Sign in to use AI-powered theme generation",
};
case "CHECKOUT":
return {
title: "Sign in to continue",
description: "Sign in to complete your purchase",
};
default:
return null;
}
}
export function AuthDialog({
@@ -27,6 +66,7 @@ export function AuthDialog({
onOpenChange,
initialMode = "signin",
trigger,
postLoginActionType,
}: AuthDialogProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -34,6 +74,8 @@ export function AuthDialog({
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const contextualCopy = getContextualCopy(postLoginActionType);
const getCallbackUrl = () => {
const baseUrl = pathname || "/editor/theme";
const queryString = searchParams.toString();
@@ -83,12 +125,13 @@ export function AuthDialog({
<div className="space-y-4">
<ResponsiveDialogHeader className="sm:pt-8">
<ResponsiveDialogTitle className="text-center text-2xl font-bold">
{isSignIn ? "Welcome back" : "Create account"}
{contextualCopy?.title ?? (isSignIn ? "Welcome back" : "Create account")}
</ResponsiveDialogTitle>
<p className="text-muted-foreground text-center">
{isSignIn
? "Sign in to your account to continue"
: "Sign up to get started with tweakcn"}
{contextualCopy?.description ??
(isSignIn
? "Sign in to your account to continue"
: "Sign up to get started with tweakcn")}
</p>
</ResponsiveDialogHeader>
+18 -2
View File
@@ -3,6 +3,8 @@ import { NextResponse } from "next/server";
import { getTheme } from "@/actions/themes";
import { generateThemeRegistryItemFromStyles } from "@/utils/registry/themes";
import { registryItemSchema } from "shadcn/registry";
import { getBuiltInThemeStyles } from "@/utils/theme-preset-helper";
import { ThemeStyles } from "@/types/theme";
export const dynamic = "force-static";
@@ -10,8 +12,22 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
const { id } = await params;
try {
const theme = await getTheme(id);
const generatedRegistryItem = generateThemeRegistryItemFromStyles(theme.name, theme.styles);
// First, check if this is a built-in theme
let themeName: string;
let themeStyles: ThemeStyles;
const builtInTheme = getBuiltInThemeStyles(id.replace(/\.json$/, ""));
if (builtInTheme) {
themeName = builtInTheme.name;
themeStyles = builtInTheme.styles;
} else {
// Fall back to database lookup for user-saved themes
const theme = await getTheme(id);
themeName = theme.name;
themeStyles = theme.styles;
}
const generatedRegistryItem = generateThemeRegistryItemFromStyles(themeName, themeStyles);
// Validate the generated registry item against the official shadcn registry item schema
// https://ui.shadcn.com/docs/registry/registry-item-json
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { getTheme } from "@/actions/themes";
import { generateV0RegistryPayload } from "@/utils/registry/v0";
import { getBuiltInThemeStyles } from "@/utils/theme-preset-helper";
export const dynamic = "force-static";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
// First, check if this is a built-in theme
const builtInTheme = getBuiltInThemeStyles(id.replace(/\.json$/, ""));
if (builtInTheme) {
const payload = generateV0RegistryPayload(builtInTheme.name, builtInTheme.styles);
return new NextResponse(JSON.stringify(payload), {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
}
// Fall back to database lookup for user-saved themes
const theme = await getTheme(id);
const payload = generateV0RegistryPayload(theme.name, theme.styles);
return new NextResponse(JSON.stringify(payload), {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Error generating v0 registry payload:", error);
const isNotFound =
error instanceof Error &&
(error.name === "ThemeNotFoundError" || error.message.includes("not found"));
return new NextResponse(isNotFound ? "Theme not found" : "Failed to generate v0 payload", {
status: isNotFound ? 404 : 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
+8 -1
View File
@@ -32,5 +32,12 @@ export function AuthDialogWrapper() {
}
}, [session, isOpen, closeAuthDialog, postLoginAction, clearPostLoginAction, posthog]);
return <AuthDialog open={isOpen} onOpenChange={closeAuthDialog} initialMode={mode} />;
return (
<AuthDialog
open={isOpen}
onOpenChange={closeAuthDialog}
initialMode={mode}
postLoginActionType={postLoginAction?.type}
/>
);
}
+1 -9
View File
@@ -2,17 +2,9 @@
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";
import { useDialogActions } from "@/hooks/use-dialog-actions";
export function ActionBar() {
return (
<DialogActionsProvider>
<ActionBarContent />
</DialogActionsProvider>
);
}
function ActionBarContent() {
const { isCreatingTheme, handleSaveClick, handleShareClick, setCssImportOpen, setCodePanelOpen } =
useDialogActions();
+60 -55
View File
@@ -2,6 +2,8 @@
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DialogActionsProvider } from "@/hooks/use-dialog-actions";
import { useIsMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/store/editor-store";
import { Theme, ThemeStyles } from "@/types/theme";
import { Sliders } from "lucide-react";
@@ -9,7 +11,6 @@ import React, { use, useEffect } from "react";
import { ActionBar } from "./action-bar/action-bar";
import ThemeControlPanel from "./theme-control-panel";
import ThemePreviewPanel from "./theme-preview-panel";
import { useIsMobile } from "@/hooks/use-mobile";
interface EditorProps {
themePromise: Promise<Theme | null>;
@@ -64,20 +65,55 @@ const Editor: React.FC<EditorProps> = ({ themePromise }) => {
// Mobile layout
if (isMobile) {
return (
<DialogActionsProvider>
<div className="relative isolate flex flex-1 overflow-hidden">
<div className="size-full flex-1 overflow-hidden">
<Tabs defaultValue="controls" className="h-full">
<TabsList className="w-full rounded-none">
<TabsTrigger value="controls" className="flex-1">
<Sliders className="mr-2 h-4 w-4" />
Controls
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Preview
</TabsTrigger>
</TabsList>
<TabsContent value="controls" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<ThemeControlPanel
styles={styles}
onChange={handleStyleChange}
currentMode={themeState.currentMode}
themePromise={themePromise}
/>
</div>
</TabsContent>
<TabsContent value="preview" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<ActionBar />
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
</div>
</TabsContent>
</Tabs>
</div>
</div>
</DialogActionsProvider>
);
}
// Desktop layout
return (
<DialogActionsProvider>
<div className="relative isolate flex flex-1 overflow-hidden">
<div className="size-full flex-1 overflow-hidden">
<Tabs defaultValue="controls" className="h-full">
<TabsList className="w-full rounded-none">
<TabsTrigger value="controls" className="flex-1">
<Sliders className="mr-2 h-4 w-4" />
Controls
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Preview
</TabsTrigger>
</TabsList>
<TabsContent value="controls" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<div className="size-full">
<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}
@@ -85,51 +121,20 @@ const Editor: React.FC<EditorProps> = ({ themePromise }) => {
themePromise={themePromise}
/>
</div>
</TabsContent>
<TabsContent value="preview" className="mt-0 h-[calc(100%-2.5rem)]">
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={70}>
<div className="flex h-full flex-col">
<ActionBar />
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
<div className="flex min-h-0 flex-1 flex-col">
<ActionBar />
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
</div>
</div>
</TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}
// Desktop layout
return (
<div className="relative isolate flex flex-1 overflow-hidden">
<div className="size-full">
<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}
themePromise={themePromise}
/>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={70}>
<div className="flex h-full flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<ActionBar />
<ThemePreviewPanel styles={styles} currentMode={themeState.currentMode} />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</DialogActionsProvider>
);
};
+23 -1
View File
@@ -13,18 +13,19 @@ import {
} from "@/components/ui/dropdown-menu";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { useDialogActions } from "@/hooks/use-dialog-actions";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useThemeInspector } from "@/hooks/use-theme-inspector";
import { cn } from "@/lib/utils";
import { ThemeEditorPreviewProps } from "@/types/theme";
import { Inspect, Maximize, Minimize, MoreVertical } from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { lazy } from "react";
import InspectorOverlay from "./inspector-overlay";
import ColorPreview from "./theme-preview/color-preview";
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
import { useQueryState } from "nuqs";
const DemoCards = lazy(() => import("@/components/examples/cards"));
const DemoMail = lazy(() => import("@/components/examples/mail"));
@@ -33,11 +34,25 @@ const DemoPricing = lazy(() => import("@/components/examples/pricing/pricing"));
const TypographyDemo = lazy(() => import("@/components/examples/typography/typography-demo"));
const CustomDemo = lazy(() => import("@/components/examples/custom"));
const V0Logo = ({ className }: { className?: string }) => (
<svg viewBox="0 0 40 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<path
d="M23.3919 0H32.9188C36.7819 0 39.9136 3.13165 39.9136 6.99475V16.0805H36.0006V6.99475C36.0006 6.90167 35.9969 6.80925 35.9898 6.71766L26.4628 16.079C26.4949 16.08 26.5272 16.0805 26.5595 16.0805H36.0006V19.7762H26.5595C22.6964 19.7762 19.4788 16.6139 19.4788 12.7508V3.68923H23.3919V12.7508C23.3919 12.9253 23.4054 13.0977 23.4316 13.2668L33.1682 3.6995C33.0861 3.6927 33.003 3.68923 32.9188 3.68923H23.3919V0Z"
fill="currentColor"
/>
<path
d="M13.7688 19.0956L0 3.68759H5.53933L13.6231 12.7337V3.68759H17.7535V17.5746C17.7535 19.6705 15.1654 20.6584 13.7688 19.0956Z"
fill="currentColor"
/>
</svg>
);
const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) => {
const { isFullscreen, toggleFullscreen } = useFullscreen();
const [activeTab, setActiveTab] = useQueryState("p", {
defaultValue: "cards",
});
const { handleOpenInV0 } = useDialogActions();
const {
rootRef,
@@ -98,6 +113,13 @@ const ThemePreviewPanel = ({ styles, currentMode }: ThemeEditorPreviewProps) =>
</TabsList>
<div className="flex items-center gap-0.5">
<TooltipWrapper label="Open theme in v0" asChild>
<Button variant="ghost" onClick={() => handleOpenInV0()} className="group px-2.5">
<span className="flex items-center justify-center gap-1 transition-all group-hover:scale-110">
Open in <V0Logo className="mb-0.5 !size-5" />
</span>
</Button>
</TooltipWrapper>
{isFullscreen && (
<ThemeToggle
variant="ghost"
+81 -8
View File
@@ -14,6 +14,28 @@ import { parseCssInput } from "@/utils/parse-css-input";
import { usePostHog } from "posthog-js/react";
import { createContext, ReactNode, useContext, useState } from "react";
type PendingAction = "share" | "v0" | null;
// Get contextual copy for the save dialog based on the pending action
function getSaveDialogCopy(pendingAction: PendingAction) {
switch (pendingAction) {
case "share":
return {
title: "Save to share",
description: "Save your theme first to share it with others.",
ctaLabel: "Save & Share",
};
case "v0":
return {
title: "Save to open in v0",
description: "Save your theme first to open it in v0.",
ctaLabel: "Save & Open in v0",
};
default:
return {};
}
}
interface DialogActionsContextType {
// Dialog states
cssImportOpen: boolean;
@@ -24,6 +46,7 @@ interface DialogActionsContextType {
dialogKey: number;
isCreatingTheme: boolean;
isGeneratingTheme: boolean;
pendingAction: PendingAction;
// Dialog actions
setCssImportOpen: (open: boolean) => void;
@@ -33,8 +56,9 @@ interface DialogActionsContextType {
// Handler functions
handleCssImport: (css: string) => void;
handleSaveClick: (options?: { shareAfterSave?: boolean }) => void;
handleSaveClick: (options?: { shareAfterSave?: boolean; openInV0AfterSave?: boolean }) => void;
handleShareClick: (id?: string) => Promise<void>;
handleOpenInV0: (id?: string) => void;
saveTheme: (themeName: string) => Promise<void>;
}
@@ -42,7 +66,7 @@ function useDialogActionsStore(): DialogActionsContextType {
const [cssImportOpen, setCssImportOpen] = useState(false);
const [codePanelOpen, setCodePanelOpen] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [shareAfterSave, setShareAfterSave] = useState(false);
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [shareUrl, setShareUrl] = useState("");
const [dialogKey, _setDialogKey] = useState(0);
@@ -62,7 +86,12 @@ function useDialogActionsStore(): DialogActionsContextType {
usePostLoginAction("SAVE_THEME_FOR_SHARE", () => {
setSaveDialogOpen(true);
setShareAfterSave(true);
setPendingAction("share");
});
usePostLoginAction("SAVE_THEME_FOR_V0", () => {
setSaveDialogOpen(true);
setPendingAction("v0");
});
const handleCssImport = (css: string) => {
@@ -84,15 +113,21 @@ function useDialogActionsStore(): DialogActionsContextType {
});
};
const handleSaveClick = (options?: { shareAfterSave?: boolean }) => {
const handleSaveClick = (options?: { shareAfterSave?: boolean; openInV0AfterSave?: boolean }) => {
if (!session) {
openAuthDialog("signin", options?.shareAfterSave ? "SAVE_THEME_FOR_SHARE" : "SAVE_THEME");
let action: "SAVE_THEME" | "SAVE_THEME_FOR_SHARE" | "SAVE_THEME_FOR_V0" = "SAVE_THEME";
if (options?.shareAfterSave) action = "SAVE_THEME_FOR_SHARE";
if (options?.openInV0AfterSave) action = "SAVE_THEME_FOR_V0";
openAuthDialog("signin", action);
return;
}
setSaveDialogOpen(true);
if (options?.shareAfterSave) {
setShareAfterSave(true);
setPendingAction("share");
}
if (options?.openInV0AfterSave) {
setPendingAction("v0");
}
};
@@ -110,9 +145,12 @@ function useDialogActionsStore(): DialogActionsContextType {
});
if (!theme) return;
applyThemePreset(theme?.id || themeState.preset || "default");
if (shareAfterSave) {
if (pendingAction === "share") {
handleShareClick(theme?.id);
setShareAfterSave(false);
setPendingAction(null);
} else if (pendingAction === "v0") {
openInV0(theme?.id);
setPendingAction(null);
}
setTimeout(() => {
setSaveDialogOpen(false);
@@ -153,6 +191,38 @@ function useDialogActionsStore(): DialogActionsContextType {
setShareDialogOpen(true);
};
// Internal helper to open v0 with a theme
const openInV0 = (id?: string) => {
const presetId = id ?? themeState.preset;
if (!presetId) return;
const currentPreset = getPreset(presetId);
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
const themeName = currentPreset?.label || presetId;
posthog.capture("OPEN_IN_V0", {
theme_id: presetId,
theme_name: themeName,
is_saved: isSavedPreset,
});
const themeUrl = isSavedPreset
? `https://tweakcn.com/r/v0/${presetId}`
: `https://tweakcn.com/r/v0/${presetId}.json`;
const title = `"${themeName}" from tweakcn`;
const v0Url = `https://v0.dev/chat/api/open?url=${encodeURIComponent(themeUrl)}&title=${encodeURIComponent(title)}`;
window.open(v0Url, "_blank", "noopener,noreferrer");
};
const handleOpenInV0 = (id?: string) => {
if (hasThemeChangedFromCheckpoint()) {
handleSaveClick({ openInV0AfterSave: true });
return;
}
openInV0(id);
};
const value = {
// Dialog states
cssImportOpen,
@@ -163,6 +233,7 @@ function useDialogActionsStore(): DialogActionsContextType {
dialogKey,
isCreatingTheme: createThemeMutation.isPending,
isGeneratingTheme,
pendingAction,
// Dialog actions
setCssImportOpen,
@@ -174,6 +245,7 @@ function useDialogActionsStore(): DialogActionsContextType {
handleCssImport,
handleSaveClick,
handleShareClick,
handleOpenInV0,
saveTheme,
};
@@ -206,6 +278,7 @@ export function DialogActionsProvider({ children }: { children: ReactNode }) {
onOpenChange={store.setSaveDialogOpen}
onSave={store.saveTheme}
isSaving={store.isCreatingTheme}
{...getSaveDialogCopy(store.pendingAction)}
/>
<ShareDialog
open={store.shareDialogOpen}
+1
View File
@@ -16,6 +16,7 @@ export type PostLoginActionType =
| "AI_GENERATE_EDIT"
| "AI_GENERATE_RETRY"
| "SAVE_THEME_FOR_SHARE"
| "SAVE_THEME_FOR_V0"
| "CHECKOUT";
export interface PostLoginActionPayload<T = any> {
+47 -5
View File
@@ -1,20 +1,62 @@
import fs from "fs";
import path from "path";
import { generateThemeRegistryFromPreset } from "@/utils/registry/themes";
import { generateThemeRegistryItemFromStyles } from "@/utils/registry/themes";
import { generateV0RegistryPayload } from "@/utils/registry/v0";
import { defaultPresets } from "@/utils/theme-presets";
import { defaultThemeState } from "@/config/theme";
import { ThemeStyles } from "@/types/theme";
const THEMES_DIR = path.join(process.cwd(), "public", "r", "themes");
const V0_DIR = path.join(process.cwd(), "public", "r", "v0");
// Ensure the themes directory exists
if (!fs.existsSync(THEMES_DIR)) {
fs.mkdirSync(THEMES_DIR, { recursive: true });
// Ensure directories exist
[THEMES_DIR, V0_DIR].forEach((dir) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// Helper to get preset theme styles without going through the store
function getPresetThemeStylesForScript(name: string): ThemeStyles {
const defaultTheme = defaultThemeState.styles;
if (name === "default") {
return defaultTheme;
}
const preset = defaultPresets[name];
if (!preset) {
return defaultTheme;
}
return {
light: {
...defaultTheme.light,
...(preset.styles.light || {}),
},
dark: {
...defaultTheme.dark,
...(preset.styles.light || {}),
...(preset.styles.dark || {}),
},
};
}
// Generate registry files for all presets
Object.keys(defaultPresets).forEach((name) => {
const registryItem = generateThemeRegistryFromPreset(name);
const preset = defaultPresets[name];
const themeStyles = getPresetThemeStylesForScript(name);
const themeName = preset.label || name;
// Generate shadcn registry format
const registryItem = generateThemeRegistryItemFromStyles(name, themeStyles);
const filePath = path.join(THEMES_DIR, `${name}.json`);
fs.writeFileSync(filePath, JSON.stringify(registryItem, null, 2));
console.log(`Generated registry file for theme: ${name}`);
// Generate v0 format
const v0Payload = generateV0RegistryPayload(themeName, themeStyles);
const v0FilePath = path.join(V0_DIR, `${name}.json`);
fs.writeFileSync(v0FilePath, JSON.stringify(v0Payload, null, 2));
console.log(`Generated v0 file for theme: ${name}`);
});
+402
View File
@@ -0,0 +1,402 @@
import { ThemeStyles, ThemeStyleProps } from "@/types/theme";
import { colorFormatter } from "@/utils/color-converter";
import { getShadowMap } from "@/utils/shadows";
import { defaultLightThemeStyles, defaultDarkThemeStyles } from "@/config/theme";
import { SYSTEM_FONTS } from "@/utils/fonts";
type FontConfig = {
family: string;
variable: string;
variableName: string;
};
function extractFontFamily(fontFamilyValue: string): string | null {
if (!fontFamilyValue) return null;
const firstFont = fontFamilyValue.split(",")[0].trim();
const cleanFont = firstFont.replace(/['"]/g, "");
if (SYSTEM_FONTS.includes(cleanFont.toLowerCase())) return null;
return cleanFont;
}
function toVariableName(fontFamily: string): string {
// "Plus Jakarta Sans" -> "plusJakartaSans"
const words = fontFamily.split(/\s+/);
return words
.map((word, i) => (i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1)))
.join("");
}
function toCssVariable(fontFamily: string): string {
// "Plus Jakarta Sans" -> "--font-plus-jakarta-sans"
return `--font-${fontFamily.toLowerCase().replace(/\s+/g, "-")}`;
}
function extractGoogleFonts(themeStyles: ThemeStyles): FontConfig[] {
const fonts: FontConfig[] = [];
const seen = new Set<string>();
const fontKeys: (keyof ThemeStyleProps)[] = ["font-sans", "font-serif", "font-mono"];
for (const key of fontKeys) {
const fontValue = themeStyles.light[key] || themeStyles.dark[key];
if (!fontValue) continue;
const family = extractFontFamily(fontValue);
if (!family || seen.has(family)) continue;
seen.add(family);
fonts.push({
family,
variable: toCssVariable(family),
variableName: toVariableName(family),
});
}
return fonts;
}
function formatColor(color: string): string {
return colorFormatter(color, "oklch");
}
function generateColorVariables(styles: ThemeStyleProps): string {
return ` --background: ${formatColor(styles.background)};
--foreground: ${formatColor(styles.foreground)};
--card: ${formatColor(styles.card)};
--card-foreground: ${formatColor(styles["card-foreground"])};
--popover: ${formatColor(styles.popover)};
--popover-foreground: ${formatColor(styles["popover-foreground"])};
--primary: ${formatColor(styles.primary)};
--primary-foreground: ${formatColor(styles["primary-foreground"])};
--secondary: ${formatColor(styles.secondary)};
--secondary-foreground: ${formatColor(styles["secondary-foreground"])};
--muted: ${formatColor(styles.muted)};
--muted-foreground: ${formatColor(styles["muted-foreground"])};
--accent: ${formatColor(styles.accent)};
--accent-foreground: ${formatColor(styles["accent-foreground"])};
--destructive: ${formatColor(styles.destructive)};
--destructive-foreground: ${formatColor(styles["destructive-foreground"])};
--border: ${formatColor(styles.border)};
--input: ${formatColor(styles.input)};
--ring: ${formatColor(styles.ring)};
--chart-1: ${formatColor(styles["chart-1"])};
--chart-2: ${formatColor(styles["chart-2"])};
--chart-3: ${formatColor(styles["chart-3"])};
--chart-4: ${formatColor(styles["chart-4"])};
--chart-5: ${formatColor(styles["chart-5"])};
--radius: ${styles.radius};
--sidebar: ${formatColor(styles.sidebar)};
--sidebar-foreground: ${formatColor(styles["sidebar-foreground"])};
--sidebar-primary: ${formatColor(styles["sidebar-primary"])};
--sidebar-primary-foreground: ${formatColor(styles["sidebar-primary-foreground"])};
--sidebar-accent: ${formatColor(styles["sidebar-accent"])};
--sidebar-accent-foreground: ${formatColor(styles["sidebar-accent-foreground"])};
--sidebar-border: ${formatColor(styles["sidebar-border"])};
--sidebar-ring: ${formatColor(styles["sidebar-ring"])};`;
}
export function generateV0GlobalsCss(themeStyles: ThemeStyles): string {
const light = { ...defaultLightThemeStyles, ...themeStyles.light };
const dark = { ...defaultDarkThemeStyles, ...themeStyles.dark };
const lightShadows = getShadowMap({ styles: { light, dark }, currentMode: "light" });
const darkShadows = getShadowMap({ styles: { light, dark }, currentMode: "dark" });
const lightVars = generateColorVariables(light);
const darkVars = generateColorVariables(dark);
// Get font values with fallbacks
// Transform "Roboto Mono, sans-serif" -> "Roboto Mono, Roboto Mono Fallback"
const formatFontWithFallback = (fontValue: string): string => {
const firstFont = fontValue.split(",")[0].trim().replace(/['"]/g, "");
return `${firstFont}, ${firstFont} Fallback`;
};
const fontSans = formatFontWithFallback(
light["font-sans"] || defaultLightThemeStyles["font-sans"]
);
const fontMono = formatFontWithFallback(
light["font-mono"] || defaultLightThemeStyles["font-mono"]
);
const fontSerif = formatFontWithFallback(
light["font-serif"] || defaultLightThemeStyles["font-serif"]
);
return `@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: ${fontSans};
--font-mono: ${fontMono};
--font-serif: ${fontSerif};
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
${lightVars}
--shadow-2xs: ${lightShadows["shadow-2xs"]};
--shadow-xs: ${lightShadows["shadow-xs"]};
--shadow-sm: ${lightShadows["shadow-sm"]};
--shadow: ${lightShadows["shadow"]};
--shadow-md: ${lightShadows["shadow-md"]};
--shadow-lg: ${lightShadows["shadow-lg"]};
--shadow-xl: ${lightShadows["shadow-xl"]};
--shadow-2xl: ${lightShadows["shadow-2xl"]};
}
.dark {
${darkVars}
--shadow-2xs: ${darkShadows["shadow-2xs"]};
--shadow-xs: ${darkShadows["shadow-xs"]};
--shadow-sm: ${darkShadows["shadow-sm"]};
--shadow: ${darkShadows["shadow"]};
--shadow-md: ${darkShadows["shadow-md"]};
--shadow-lg: ${darkShadows["shadow-lg"]};
--shadow-xl: ${darkShadows["shadow-xl"]};
--shadow-2xl: ${darkShadows["shadow-2xl"]};
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}`;
}
export function generateV0LayoutTsx(themeStyles: ThemeStyles): string {
const fonts = extractGoogleFonts(themeStyles);
const hasFonts = fonts.length > 0;
// Generate font imports
const fontImports = hasFonts
? `import { ${fonts.map((f) => f.family.replace(/\s+/g, "_")).join(", ")} } from "next/font/google";`
: "";
// Generate font declarations
const fontDeclarations = fonts
.map(
(f) => `const _${f.variableName} = ${f.family.replace(/\s+/g, "_")}({ subsets: ["latin"] });`
)
.join("\n");
const htmlClassName = hasFonts ? `className="font-sans"` : "";
return `import type { Metadata } from "next";
${fontImports}
import "./globals.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
${fontDeclarations}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" ${htmlClassName}>
<body className="antialiased">
{children}
</body>
</html>
);
}`;
}
export function generateV0PageTsx(themeName: string): string {
return `function ColorSwatch({ name, bgClass, label }: { name: string; bgClass: string; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={\`w-12 h-12 rounded-md border shrink-0 \${bgClass}\`} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{label}</p>
<p className="text-xs text-muted-foreground font-mono">{name}</p>
</div>
</div>
);
}
export default function Page() {
return (
<main className="min-h-screen p-8 bg-background text-foreground">
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h1 className="text-4xl font-bold">${themeName}</h1>
<p className="text-muted-foreground mt-2">A theme from tweakcn.</p>
</div>
{/* Primary Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Primary Colors</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorSwatch name="background" bgClass="bg-background" label="Background" />
<ColorSwatch name="foreground" bgClass="bg-foreground" label="Foreground" />
<ColorSwatch name="primary" bgClass="bg-primary" label="Primary" />
<ColorSwatch name="primary-foreground" bgClass="bg-primary-foreground" label="Primary Foreground" />
</div>
</section>
{/* Secondary & Accent Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Secondary & Accent</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorSwatch name="secondary" bgClass="bg-secondary" label="Secondary" />
<ColorSwatch name="secondary-foreground" bgClass="bg-secondary-foreground" label="Secondary Foreground" />
<ColorSwatch name="accent" bgClass="bg-accent" label="Accent" />
<ColorSwatch name="accent-foreground" bgClass="bg-accent-foreground" label="Accent Foreground" />
</div>
</section>
{/* UI Component Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">UI Components</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorSwatch name="card" bgClass="bg-card" label="Card" />
<ColorSwatch name="card-foreground" bgClass="bg-card-foreground" label="Card Foreground" />
<ColorSwatch name="popover" bgClass="bg-popover" label="Popover" />
<ColorSwatch name="popover-foreground" bgClass="bg-popover-foreground" label="Popover Foreground" />
<ColorSwatch name="muted" bgClass="bg-muted" label="Muted" />
<ColorSwatch name="muted-foreground" bgClass="bg-muted-foreground" label="Muted Foreground" />
</div>
</section>
{/* Utility & Form Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Utility & Form</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorSwatch name="border" bgClass="bg-border" label="Border" />
<ColorSwatch name="input" bgClass="bg-input" label="Input" />
<ColorSwatch name="ring" bgClass="bg-ring" label="Ring" />
</div>
</section>
{/* Status Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Status</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorSwatch name="destructive" bgClass="bg-destructive" label="Destructive" />
<ColorSwatch name="destructive-foreground" bgClass="bg-destructive-foreground" label="Destructive Foreground" />
</div>
</section>
{/* Chart Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Chart Colors</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<ColorSwatch name="chart-1" bgClass="bg-chart-1" label="Chart 1" />
<ColorSwatch name="chart-2" bgClass="bg-chart-2" label="Chart 2" />
<ColorSwatch name="chart-3" bgClass="bg-chart-3" label="Chart 3" />
<ColorSwatch name="chart-4" bgClass="bg-chart-4" label="Chart 4" />
<ColorSwatch name="chart-5" bgClass="bg-chart-5" label="Chart 5" />
</div>
</section>
{/* Sidebar Colors */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Sidebar</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<ColorSwatch name="sidebar" bgClass="bg-sidebar" label="Sidebar" />
<ColorSwatch name="sidebar-foreground" bgClass="bg-sidebar-foreground" label="Sidebar Foreground" />
<ColorSwatch name="sidebar-primary" bgClass="bg-sidebar-primary" label="Sidebar Primary" />
<ColorSwatch name="sidebar-primary-foreground" bgClass="bg-sidebar-primary-foreground" label="Sidebar Primary FG" />
<ColorSwatch name="sidebar-accent" bgClass="bg-sidebar-accent" label="Sidebar Accent" />
<ColorSwatch name="sidebar-accent-foreground" bgClass="bg-sidebar-accent-foreground" label="Sidebar Accent FG" />
<ColorSwatch name="sidebar-border" bgClass="bg-sidebar-border" label="Sidebar Border" />
<ColorSwatch name="sidebar-ring" bgClass="bg-sidebar-ring" label="Sidebar Ring" />
</div>
</section>
</div>
</main>
);
}`;
}
export type V0RegistryFile = {
path: string;
content: string;
type: "registry:file" | "registry:page";
target: string;
};
export type V0RegistryPayload = {
name: string;
files: V0RegistryFile[];
type: "registry:item";
};
export function generateV0RegistryPayload(
themeName: string,
themeStyles: ThemeStyles
): V0RegistryPayload {
return {
name: themeName,
type: "registry:item",
files: [
{
path: "app/globals.css",
content: generateV0GlobalsCss(themeStyles),
type: "registry:file",
target: "app/globals.css",
},
{
path: "app/layout.tsx",
content: generateV0LayoutTsx(themeStyles),
type: "registry:page",
target: "app/layout.tsx",
},
{
path: "app/page.tsx",
content: generateV0PageTsx(themeName),
type: "registry:page",
target: "app/page.tsx",
},
],
};
}
+40 -14
View File
@@ -1,28 +1,54 @@
import { defaultThemeState } from "../config/theme";
import { ThemeStyles } from "../types/theme";
import { useThemePresetStore } from "../store/theme-preset-store";
import { defaultPresets } from "./theme-presets";
/**
* Get built-in theme styles by name (without using store).
* Use this for server-side code where store access is not available.
* Returns null if the preset doesn't exist.
*/
export function getBuiltInThemeStyles(name: string): { name: string; styles: ThemeStyles } | null {
const preset = defaultPresets[name];
if (!preset) {
return null;
}
const styles = mergePresetWithDefaults(preset.styles);
return {
name: preset.label || name,
styles,
};
}
function mergePresetWithDefaults(presetStyles: {
light?: Partial<ThemeStyles["light"]>;
dark?: Partial<ThemeStyles["dark"]>;
}): ThemeStyles {
const defaultTheme = defaultThemeState.styles;
return {
light: {
...defaultTheme.light,
...(presetStyles.light || {}),
},
dark: {
...defaultTheme.dark,
...(presetStyles.light || {}),
...(presetStyles.dark || {}),
},
};
}
export function getPresetThemeStyles(name: string): ThemeStyles {
const defaultTheme = defaultThemeState.styles;
if (name === "default") {
return defaultTheme;
return defaultThemeState.styles;
}
const store = useThemePresetStore.getState();
const preset = store.getPreset(name);
if (!preset) {
return defaultTheme;
return defaultThemeState.styles;
}
return {
light: {
...defaultTheme.light,
...(preset.styles.light || {}),
},
dark: {
...defaultTheme.dark,
...(preset.styles.light || {}),
...(preset.styles.dark || {}),
},
};
return mergePresetWithDefaults(preset.styles);
}