diff --git a/apps/web/app/[username]/[component_slug]/page.client.tsx b/apps/web/app/[username]/[component_slug]/page.client.tsx index 7f0228af..1584f7ef 100644 --- a/apps/web/app/[username]/[component_slug]/page.client.tsx +++ b/apps/web/app/[username]/[component_slug]/page.client.tsx @@ -54,7 +54,7 @@ import { BookmarkButton } from "@/components/ui/bookmark-button" import { ThemeToggle } from "../../../components/ui/theme-toggle" import { ComponentPagePreview } from "../../../components/features/component-page/component-preview" import { EditComponentDialog } from "../../../components/ui/edit-component-dialog" -import { usePublishAs } from "../../../components/features/publish/hooks/use-publish-as" +import { usePublishAs } from "../../../components/features/publish-old/hooks/use-publish-as" import { Icons } from "@/components/icons" import { CopyPromptDialog } from "@/components/ui/copy-prompt-dialog" diff --git a/apps/web/app/admin/submissions/page.tsx b/apps/web/app/admin/submissions/page.tsx index 9127c410..ffce2b0f 100644 --- a/apps/web/app/admin/submissions/page.tsx +++ b/apps/web/app/admin/submissions/page.tsx @@ -2,7 +2,7 @@ import { FC } from "react" import { motion } from "motion/react" -import { useIsAdmin } from "@/components/features/publish/hooks/use-is-admin" +import { useIsAdmin } from "@/components/features/publish-old/hooks/use-is-admin" import AdminHeader from "@/components/features/admin/AdminHeader" import SubmissionCard from "@/components/features/admin/SubmissionCard" import ManageSubmissionModal from "@/components/features/admin/ManageSubmissionModal" diff --git a/apps/web/app/api/sandbox/connect/route.ts b/apps/web/app/api/sandbox/connect/route.ts new file mode 100644 index 00000000..718ff119 --- /dev/null +++ b/apps/web/app/api/sandbox/connect/route.ts @@ -0,0 +1,57 @@ +import { auth } from "@clerk/nextjs/server" +import { NextResponse } from "next/server" +import { supabaseWithAdminAccess } from "@/lib/supabase" +import { + codesandboxSdk, + DEFAULT_HIBERNATION_TIMEOUT, +} from "@/lib/codesandbox-sdk" +import ShortUUID from "short-uuid" + +export async function POST(request: Request) { + try { + const { userId } = await auth() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { shortSandboxId } = await request.json() + + const sandboxId = ShortUUID().toUUID(shortSandboxId) + + if (!sandboxId) { + return NextResponse.json( + { error: "Sandbox ID is required" }, + { status: 400 }, + ) + } + + const { data: sandbox, error } = await supabaseWithAdminAccess + .from("sandboxes") + .select("codesandbox_id, name, id, component_id") + .eq("id", sandboxId) + .eq("user_id", userId) + .single() + + if (error || !sandbox) { + return NextResponse.json( + { error: "Sandbox not found or access denied" }, + { status: 404 }, + ) + } + + const startData = await codesandboxSdk.sandbox.start( + sandbox.codesandbox_id, + { + hibernationTimeoutSeconds: DEFAULT_HIBERNATION_TIMEOUT, + }, + ) + + return NextResponse.json({ success: true, startData, sandbox }) + } catch (error) { + console.error("Error connecting to sandbox:", error) + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/sandbox/edit/route.ts b/apps/web/app/api/sandbox/edit/route.ts new file mode 100644 index 00000000..a483aafd --- /dev/null +++ b/apps/web/app/api/sandbox/edit/route.ts @@ -0,0 +1,66 @@ +import { auth } from "@clerk/nextjs/server" +import { NextResponse } from "next/server" +import { supabaseWithAdminAccess } from "@/lib/supabase" +import ShortUUID from "short-uuid" + +export async function PUT(request: Request) { + try { + const { userId } = await auth() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { shortSandboxId, ...updateData } = await request.json() + + if (!shortSandboxId) { + return NextResponse.json( + { error: "Sandbox ID is required" }, + { status: 400 }, + ) + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: "No update data provided" }, + { status: 400 }, + ) + } + + const sandboxId = ShortUUID().toUUID(shortSandboxId) + + const { data: sandbox, error } = await supabaseWithAdminAccess + .from("sandboxes") + .select("id") + .eq("id", sandboxId) + .eq("user_id", userId) + .single() + + if (error || !sandbox) { + return NextResponse.json( + { error: "Sandbox not found or access denied" }, + { status: 404 }, + ) + } + + const { error: updateError } = await supabaseWithAdminAccess + .from("sandboxes") + .update(updateData) + .eq("id", sandboxId) + .eq("user_id", userId) + + if (updateError) { + return NextResponse.json( + { error: "Failed to update sandbox" }, + { status: 500 }, + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error updating sandbox:", error) + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/sandbox/new/route.ts b/apps/web/app/api/sandbox/new/route.ts new file mode 100644 index 00000000..07b2b450 --- /dev/null +++ b/apps/web/app/api/sandbox/new/route.ts @@ -0,0 +1,61 @@ +import { auth } from "@clerk/nextjs/server" +import { NextResponse } from "next/server" +import { supabaseWithAdminAccess } from "@/lib/supabase" +import ShortUUID from "short-uuid" +import { + DEFAULT_HIBERNATION_TIMEOUT, + DEFAULT_TEMPLATE, + TEMPLATES, + codesandboxSdk, +} from "@/lib/codesandbox-sdk" + +export async function POST() { + try { + const { userId } = await auth() + + console.log("userId", userId) + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + console.log("Creating CodeSandbox instance...") + const sandbox = await codesandboxSdk.sandbox.create({ + template: TEMPLATES[DEFAULT_TEMPLATE], + hibernationTimeoutSeconds: DEFAULT_HIBERNATION_TIMEOUT, + privacy: "public", // Public visibility + }) + + const codesandboxId = sandbox.id + console.log(`CodeSandbox instance created: ${codesandboxId}`) + + const now = new Date().toISOString() + const { data: dbSandbox, error: dbError } = await supabaseWithAdminAccess + .from("sandboxes") + .insert({ + user_id: userId, + codesandbox_id: codesandboxId, + created_at: now, + updated_at: now, + }) + .select() + .single() + + if (dbError) { + console.error("Error storing sandbox:", dbError) + return new NextResponse("Failed to save sandbox data", { status: 500 }) + } + + console.log(`Sandbox created and stored with ID: ${dbSandbox.id}`) + + const shortId = ShortUUID().fromUUID(dbSandbox.id) + + return NextResponse.json({ + success: true, + shortSandboxId: shortId, + }) + } catch (error) { + console.error("Error creating sandbox:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/apps/web/app/api/sandbox/publish/route.ts b/apps/web/app/api/sandbox/publish/route.ts new file mode 100644 index 00000000..70660376 --- /dev/null +++ b/apps/web/app/api/sandbox/publish/route.ts @@ -0,0 +1,59 @@ +import { auth } from "@clerk/nextjs/server" +import { NextResponse } from "next/server" +import { supabaseWithAdminAccess } from "@/lib/supabase" +import ShortUUID from "short-uuid" + +export async function POST(request: Request) { + try { + const { userId } = await auth() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { shortSandboxId } = await request.json() + + const sandboxId = ShortUUID().toUUID(shortSandboxId) + + if (!sandboxId) { + return NextResponse.json( + { error: "Sandbox ID is required" }, + { status: 400 }, + ) + } + + const { data: sandbox, error } = await supabaseWithAdminAccess + .from("sandboxes") + .select("codesandbox_id") + .eq("id", sandboxId) + .eq("user_id", userId) + .single() + + if (error || !sandbox) { + return NextResponse.json( + { error: "Sandbox not found or access denied" }, + { status: 404 }, + ) + } + + const { error: updateError } = await supabaseWithAdminAccess + .from("sandboxes") + .update({ status: "on_review" }) + .eq("id", sandboxId) + .eq("user_id", userId) + + if (updateError) { + return NextResponse.json( + { error: "Failed to update sandbox status" }, + { status: 500 }, + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error connecting to sandbox:", error) + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/studio/merge-styles/globals/route.ts b/apps/web/app/api/studio/merge-styles/globals/route.ts new file mode 100644 index 00000000..912ab744 --- /dev/null +++ b/apps/web/app/api/studio/merge-styles/globals/route.ts @@ -0,0 +1,120 @@ +import { OpenAI } from "openai" +import { NextResponse } from "next/server" + +const openai = new OpenAI() + +export async function POST(request: Request) { + try { + const { defaultGlobalCss, dependencyGlobalCss } = await request.json() + + console.log("🔄 [Global CSS Merge] Request received:", { + defaultCssLength: defaultGlobalCss?.length, + dependencyCssCount: dependencyGlobalCss?.length, + dependencyCssSizes: dependencyGlobalCss?.map((css: string) => css.length), + }) + + // Only proceed if we have custom styles to merge + if (!dependencyGlobalCss?.length) { + console.log( + "⏩ [Global CSS Merge] No custom styles to merge, returning default", + ) + return NextResponse.json({ globalCss: defaultGlobalCss }) + } + + const prompt = `You are a CSS expert. Please merge the following global CSS styles into a single optimized stylesheet. + +Base Global CSS: +\`\`\`css +${defaultGlobalCss} +\`\`\` + +${dependencyGlobalCss + .map( + (css: string, index: number) => ` +Dependency ${index + 1} Global CSS: +\`\`\`css +${css} +\`\`\` +`, + ) + .join("\n")} + +Please provide a merged global.css that: +1. Combines all styles efficiently +2. Resolves any conflicts by using the most specific/latest definitions +3. Maintains all necessary functionality +4. Eliminates duplicates +5. Preserves cascade and specificity appropriately +6. Groups related styles together +7. Never shortens, simplifies, or omits any keyframe animations or complex selectors +8. Includes ALL original code without truncation or summarization +9. Preserves all vendor prefixes and browser-specific styles + +Format the response as a JSON object with a 'globalCss' key containing the complete merged styles.` + + console.log("📤 [Global CSS Merge] Sending request to OpenAI:", { + promptLength: prompt.length, + stylesToMerge: dependencyGlobalCss.length + 1, // +1 for default styles + }) + + const completion = await openai.chat.completions.create({ + messages: [ + { + role: "system", + content: + "You are a CSS expert who helps merge and optimize stylesheets. Always include all content completely without summarizing or truncating. Preserve all keyframes, animations, and complex styles fully.", + }, + { + role: "user", + content: prompt, + }, + ], + model: "gpt-4o-mini", + response_format: { type: "json_object" }, + }) + + if (!completion.choices[0]?.message.content) { + throw new Error("No content in OpenAI response") + } + + const result = JSON.parse(completion.choices[0].message.content) + + // Handle both string and object formats for globalCss + let logInfo = { + resultLength: result.globalCss + ? typeof result.globalCss === "string" + ? result.globalCss.length + : JSON.stringify(result.globalCss).length + : 0, + hasCss: !!result.globalCss, + firstFewLines: "", + } + + // Add firstFewLines only if globalCss is a string + if (typeof result.globalCss === "string") { + logInfo.firstFewLines = result.globalCss + .split("\n") + .slice(0, 3) + .join("\n") + } else if (result.globalCss) { + // For object format, convert to string representation for logging + logInfo.firstFewLines = + JSON.stringify(result.globalCss).slice(0, 100) + "..." + } + + console.log("📥 [Global CSS Merge] Received response from OpenAI:", logInfo) + + // Ensure globalCss is always a string in the response + if (result.globalCss && typeof result.globalCss !== "string") { + result.globalCss = JSON.stringify(result.globalCss, null, 2) + } + + return NextResponse.json(result) + } catch (error) { + console.error("❌ [Global CSS Merge] Error:", error) + return NextResponse.json( + { error: "Failed to merge global CSS" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/studio/merge-styles/tailwind/route.ts b/apps/web/app/api/studio/merge-styles/tailwind/route.ts new file mode 100644 index 00000000..eb533489 --- /dev/null +++ b/apps/web/app/api/studio/merge-styles/tailwind/route.ts @@ -0,0 +1,118 @@ +import { OpenAI } from "openai" +import { NextResponse } from "next/server" + +const openai = new OpenAI() + +export async function POST(request: Request) { + try { + const { defaultConfig, dependencyConfigs } = await request.json() + + console.log("🔄 [Tailwind Merge] Request received:", { + defaultConfigLength: defaultConfig?.length, + dependencyConfigsCount: dependencyConfigs?.length, + dependencyConfigSizes: dependencyConfigs?.map((c: string) => c.length), + }) + + // Only proceed if we have custom configs to merge + if (!dependencyConfigs?.length) { + console.log( + "⏩ [Tailwind Merge] No custom configs to merge, returning default", + ) + return NextResponse.json({ tailwindConfig: defaultConfig }) + } + + const prompt = `You are a Tailwind CSS expert. Please merge the following Tailwind CSS configurations into a single optimized configuration. + +Base Tailwind Config: +\`\`\`js +${defaultConfig} +\`\`\` + +${dependencyConfigs + .map( + (config: string, index: number) => ` +Dependency ${index + 1} Tailwind Config: +\`\`\`js +${config} +\`\`\` +`, + ) + .join("\n")} + +Please provide a merged Tailwind config that: +1. Combines all configurations efficiently +2. Resolves any conflicts by using the most specific/latest definitions +3. Maintains all necessary functionality +4. Eliminates duplicates +5. Preserves the structure of a valid Tailwind config +6. Includes ALL original configuration without truncation or simplification +7. Preserves all theme extensions, plugins, and customizations completely + +Format the response as a JSON object with a 'tailwindConfig' key containing the complete merged configuration.` + + console.log("📤 [Tailwind Merge] Sending request to OpenAI:", { + promptLength: prompt.length, + configsToMerge: dependencyConfigs.length + 1, // +1 for default config + }) + + const completion = await openai.chat.completions.create({ + messages: [ + { + role: "system", + content: + "You are a Tailwind CSS expert who helps merge and optimize configurations. Always include all content completely without summarizing or truncating. Never omit any configuration properties.", + }, + { + role: "user", + content: prompt, + }, + ], + model: "gpt-4o-mini", + response_format: { type: "json_object" }, + }) + + if (!completion.choices[0]?.message.content) { + throw new Error("No content in OpenAI response") + } + + const result = JSON.parse(completion.choices[0].message.content) + + // Handle both string and object formats for tailwindConfig + let logInfo = { + resultLength: result.tailwindConfig + ? typeof result.tailwindConfig === "string" + ? result.tailwindConfig.length + : JSON.stringify(result.tailwindConfig).length + : 0, + hasConfig: !!result.tailwindConfig, + firstFewLines: "", + } + + // Add firstFewLines only if tailwindConfig is a string + if (typeof result.tailwindConfig === "string") { + logInfo.firstFewLines = result.tailwindConfig + .split("\n") + .slice(0, 3) + .join("\n") + } else if (result.tailwindConfig) { + // For object format, convert to string representation for logging + logInfo.firstFewLines = + JSON.stringify(result.tailwindConfig).slice(0, 100) + "..." + } + + console.log("📥 [Tailwind Merge] Received response from OpenAI:", logInfo) + + // Ensure tailwindConfig is always a string in the response + if (result.tailwindConfig && typeof result.tailwindConfig !== "string") { + result.tailwindConfig = JSON.stringify(result.tailwindConfig, null, 2) + } + + return NextResponse.json(result) + } catch (error) { + console.error("❌ [Tailwind Merge] Error:", error) + return NextResponse.json( + { error: "Failed to merge Tailwind configurations" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/studio/preprocess-component/route.ts b/apps/web/app/api/studio/preprocess-component/route.ts new file mode 100644 index 00000000..20880936 --- /dev/null +++ b/apps/web/app/api/studio/preprocess-component/route.ts @@ -0,0 +1,389 @@ +import OpenAI from "openai" +import { NextResponse } from "next/server" +import { makeSlugFromName } from "@/components/features/publish-old/hooks/use-is-check-slug-available" +import { supabaseWithAdminAccess } from "@/lib/supabase" +import { defaultTailwindConfig, defaultGlobalCss } from "@/lib/defaults" + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}) + +// Mock response for testing when OpenAI API is not available +const MOCK_RESPONSE = { + componentName: "ExampleComponent", + registryType: "ui", + shadcnComponentsImports: [ + { name: "Button", path: "@/components/ui/button" }, + { name: "Dialog", path: "@/components/ui/dialog" }, + { name: "Input", path: "@/components/ui/input" }, + ], + unresolvedDependencyImports: [ + { + path: "@/components/ui/some-custom-component", + names: ["SomeCustomComponent", "CustomComponentPart"], + }, + ], + npmDependencies: ["react", "next", "lucide-react"], + environmentVariables: [], + additionalStyles: { + required: false, + tailwindExtensions: { + colors: {}, + animations: {}, + fontFamily: {}, + borderRadius: {}, + boxShadow: {}, + }, + cssVariables: [], + keyframes: [], + utilities: [], + }, +} + +export async function POST(request: Request) { + try { + const { code, userId } = await request.json() + + if (!code) { + return NextResponse.json( + { error: "Component code is required" }, + { status: 400 }, + ) + } + + if (!userId) { + return NextResponse.json( + { error: "User ID is required" }, + { status: 400 }, + ) + } + + // Process the component code with OpenAI + let result + try { + result = await preprocessComponent(code) + console.log("OpenAI preprocessing result:", result) + } catch (error) { + console.error("OpenAI API error:", error) + // If OpenAI fails, use mock response for testing + console.log("Using mock response due to OpenAI API unavailability") + result = MOCK_RESPONSE + } + + // Generate slug for the component + const slug = makeSlugFromName(result.componentName) + + // Check if slug is unique + const isUnique = await checkSlugUnique(slug, userId) + + // If not unique, generate a unique slug + const finalSlug = isUnique ? slug : await generateUniqueSlug(slug, userId) + + const response = { + ...result, + slug: finalSlug, + } + + console.log("Final preprocessed response:", response) + + return NextResponse.json(response) + } catch (error) { + console.error("Error preprocessing component:", error) + return NextResponse.json( + { error: "Failed to preprocess component" }, + { status: 500 }, + ) + } +} + +async function preprocessComponent(code: string) { + // Try to detect component name from code before using OpenAI + // This is a basic regex approach to find component declarations + + const response = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "system", + content: `You are a code analyzer specializing in React components. Analyze the provided React component code and extract the following information: +1. Component name (the function or class name of the component) +2. Registry type: Determine if it belongs to: + - 'ui' (basic components like Button, Card, etc.) + - 'hooks' (custom React hooks) + - 'blocks' (larger components like hero sections, pricing sections, etc.) +3. Identify any shadcn/ui imports from 'components/ui/' and list their names and paths (one of the following): + 1. Accordion (@/components/ui/accordion) + 2. Alert (@/components/ui/alert) + 3. Alert Dialog (@/components/ui/alert-dialog) + 4. Aspect Ratio (@/components/ui/aspect-ratio) + 5. Avatar (@/components/ui/avatar) + 6. Badge (@/components/ui/badge) + 7. Breadcrumb (@/components/ui/breadcrumb) + 8. Button (@/components/ui/button) + 9. Calendar (@/components/ui/calendar) + 10. Card (@/components/ui/card) + 11. Carousel (@/components/ui/carousel) + 12. Chart (@/components/ui/chart) + 13. Checkbox (@/components/ui/checkbox) + 14. Collapsible (@/components/ui/collapsible) + 15. Combobox (@/components/ui/combobox) + 16. Command (@/components/ui/command) + 17. Context Menu (@/components/ui/context-menu) + 18. Data Table (@/components/ui/data-table) + 19. Date Picker (@/components/ui/date-picker) + 20. Dialog (@/components/ui/dialog) + 21. Drawer (@/components/ui/drawer) + 22. Dropdown Menu (@/components/ui/dropdown-menu) + 23. Form (@/components/ui/form) + 24. Hover Card (@/components/ui/hover-card) + 25. Input (@/components/ui/input) + 26. Input OTP (@/components/ui/input-otp) + 27. Label (@/components/ui/label) + 28. Menubar (@/components/ui/menubar) + 29. Navigation Menu (@/components/ui/navigation-menu) + 30. Pagination (@/components/ui/pagination) + 31. Popover (@/components/ui/popover) + 32. Progress (@/components/ui/progress) + 33. Radio Group (@/components/ui/radio-group) + 34. Resizable (@/components/ui/resizable) + 35. Scroll Area (@/components/ui/scroll-area) + 36. Select (@/components/ui/select) + 37. Separator (@/components/ui/separator) + 38. Sheet (@/components/ui/sheet) + 39. Sidebar (@/components/ui/sidebar) + 40. Skeleton (@/components/ui/skeleton) + 41. Slider (@/components/ui/slider) + 42. Sonner (@/components/ui/sonner) + 43. Switch (@/components/ui/switch) + 44. Table (@/components/ui/table) + 45. Tabs (@/components/ui/tabs) + 46. Textarea (@/components/ui/textarea) + 47. Toast (@/components/ui/toast) + 48. Toggle (@/components/ui/toggle) + 49. Toggle Group (@/components/ui/toggle-group) + 50. Tooltip (@/components/ui/tooltip) +) +4. Identify any non-shadcn/ui component imports (including @/hooks and @/components/[registry]/ components, excluding npm packages and standard shadcn utilities). + IMPORTANT: + - DO NOT include imports like { cn } from "@/lib/utils" or any other utility functions. Only include actual component imports. + - List all components exported from the same file together, grouped by file path. + - The path should be based on the file location, not the component name. + - For example, if file mockup.tsx exports both Mockup and MockupFrame components, list both with "@/components/ui/mockup". + For each file path, provide an array of all component names imported from that path. + +5. List all npm package dependencies used in the component (excluding react, react-dom, tailwindcss, and any packages starting with 'next' or '@/') +6. Identify any environment variables (process.env.*) that the component requires to function correctly. +7. Analyze if the component requires additional CSS styles that are NOT already provided in the default Tailwind configuration and globals CSS provided below. + +IMPORTANT: Only include styles that are NOT already covered by the default tailwind.config.js and globals.css files provided below. +- DO NOT include any standard Tailwind classes like 'flex', 'p-4', 'text-sm', etc. +- DO NOT include CSS variables that are already defined in the globals.css provided +- DO NOT include any shadcn/ui component styles, as those are already available +- ONLY include custom CSS variables, custom keyframes, or custom utilities that the user has defined and would need to be added to make the component work correctly + +Here's the default Tailwind configuration: +\`\`\`js +${defaultTailwindConfig} +\`\`\` + +And here's the default globals CSS: +\`\`\`css +${defaultGlobalCss} +\`\`\` + +IMPORTANT RULES FOR COMPONENT PATHS: +1. Components exported from the same file MUST share the same path +2. The path should be based on the actual file location, not the component name +3. The filename in the path should match the actual file that contains the component +4. Multiple components from the same file must be listed together in a single entry with an array of names + +Example of correct path handling: +If a file "@/components/ui/mockup.tsx" exports both Mockup and MockupFrame components: +{ + "unresolvedDependencyImports": [ + { + "path": "@/components/ui/mockup", + "names": ["Mockup", "MockupFrame"] + } + ] +} + +Respond in JSON format only with the following structure: +{ + "componentName": "string", + "registryType": "ui|hooks|blocks", + "shadcnComponentsImports": [ + { + "name": "string", + "path": "@/components/ui/component-name" + } + ], + "unresolvedDependencyImports": [ + { + "path": "@/components/[registry]/component-name", + "names": ["string", "string", ...] + } + ], + "npmDependencies": ["string"], + "environmentVariables": [ + { + "name": "VARIABLE_NAME", + "description": "What this variable is used for", + "required": boolean + } + ], + "additionalStyles": { + "required": boolean, + "tailwindExtensions": { + "colors": { + "color-name": "color-value" // ONLY colors not in default config + }, + "animations": { + "animation-name": "animation-definition" // ONLY animations not in default config + }, + "fontFamily": { + "font-name": ["font-stack"] // ONLY font families not in default config + }, + "borderRadius": { + "radius-name": "radius-value" // ONLY border radius values not in default config + }, + "boxShadow": { + "shadow-name": "shadow-value" // ONLY shadows not in default config + }, + "spacing": { + "spacing-name": "spacing-value" // ONLY spacing values not in default config + } + }, + "cssVariables": [ + { + "name": "--variable-name", // ONLY CSS variables not already defined in globals.css + "value": "variable-value", + "where": "root|.dark|.component-class|etc" + } + ], + "keyframes": [ + { + "name": "keyframe-name", // ONLY keyframes not already defined in globals.css + "frames": "0% { opacity: 0 } 100% { opacity: 1 }" // The keyframe content without @keyframes wrapper + } + ], + "utilities": [ + { + "className": ".class-name", // ONLY utility classes not covered by Tailwind or globals.css + "definition": "{ property: value; property2: value2; }" // CSS properties as text + } + ] + } +}`, + }, + { + role: "user", + content: code, + }, + ], + response_format: { type: "json_object" }, + temperature: 0.1, + }) + + // Handle the case where the content might be undefined + const content = response.choices[0]?.message?.content + if (!content) { + throw new Error("No content returned from OpenAI") + } + + const data = JSON.parse(content) + + // Fix any undefined values in keyframes or utilities + if (data.additionalStyles.keyframes) { + data.additionalStyles.keyframes = data.additionalStyles.keyframes.map( + (keyframe: any) => { + // Standardize keyframe structure - ensure we always use 'name' and 'frames' + const name = keyframe.name || keyframe.keyframeName || "" + const frames = keyframe.frames || keyframe.definition || "" + + if (!frames || frames === "undefined") { + // Provide a default example keyframe definition + return { + name, + frames: + "0% { opacity: 0; transform: scale(0.95); }\n100% { opacity: 1; transform: scale(1); }", + } + } + return { name, frames } + }, + ) + } + + if (data.additionalStyles.utilities) { + data.additionalStyles.utilities = data.additionalStyles.utilities.map( + (utility: any) => { + // Standardize utility structure - ensure we always use 'className' and 'definition' + const className = utility.className || utility.name || "" + const definition = utility.definition || utility.properties || "" + + if (!definition || definition === "undefined") { + // Provide a default example utility definition + return { + className, + definition: "/* Add your custom styles here */", + } + } + return { className, definition } + }, + ) + } + + // Standardize empty objects in tailwindExtensions to prevent inconsistencies + if (data.additionalStyles.tailwindExtensions) { + const extensions = [ + "colors", + "animations", + "fontFamily", + "borderRadius", + "boxShadow", + "spacing", + ] + extensions.forEach((ext) => { + if (!data.additionalStyles.tailwindExtensions[ext]) { + data.additionalStyles.tailwindExtensions[ext] = {} + } + }) + } + + return data +} + +async function checkSlugUnique(slug: string, userId: string): Promise { + const supabase = supabaseWithAdminAccess + + const { data, error } = await supabase + .from("components") + .select("id") + .eq("component_slug", slug) + .eq("user_id", userId) + + if (error) { + console.error("Error checking slug uniqueness:", error) + return false + } + + return data?.length === 0 +} + +async function generateUniqueSlug( + baseSlug: string, + userId: string, +): Promise { + const supabase = supabaseWithAdminAccess + let newSlug = baseSlug + let isUnique = await checkSlugUnique(newSlug, userId) + let suffix = 1 + + while (!isUnique) { + newSlug = `${baseSlug}-${suffix}` + isUnique = await checkSlugUnique(newSlug, userId) + suffix += 1 + } + + return newSlug +} diff --git a/apps/web/app/import/page.client.tsx b/apps/web/app/import-old/page.client.tsx similarity index 96% rename from apps/web/app/import/page.client.tsx rename to apps/web/app/import-old/page.client.tsx index a8dd70b5..7c6d3985 100644 --- a/apps/web/app/import/page.client.tsx +++ b/apps/web/app/import-old/page.client.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useForm, FormProvider } from "react-hook-form" import { toast } from "sonner" -import { FormData } from "@/components/features/publish/config/utils" +import { FormData } from "@/components/features/publish-old/config/utils" import { useTheme } from "next-themes" import { extractDemoComponentNames, @@ -11,10 +11,10 @@ import { extractAmbigiousRegistryDependencies, extractNPMDependencies, } from "@/lib/parsers" -import { UrlInput } from "@/components/features/import/components/url-input" -import { ImportForm } from "@/components/features/import/components/import-form" -import { ImportHeader } from "@/components/features/import/components/import-header" -import { SuccessDialog } from "@/components/features/publish/components/success-dialog" +import { UrlInput } from "@/components/features/import-old/components/url-input" +import { ImportForm } from "@/components/features/import-old/components/import-form" +import { ImportHeader } from "@/components/features/import-old/components/import-header" +import { SuccessDialog } from "@/components/features/publish-old/components/success-dialog" import { useRouter } from "next/navigation" import { useUser } from "@clerk/nextjs" import { @@ -24,7 +24,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { WorkflowIcon } from "@/components/icons/workslow" -import { ResolveUnknownDependenciesAlertForm } from "@/components/features/publish/components/alerts" +import { ResolveUnknownDependenciesAlertForm } from "@/components/features/publish-old/components/alerts" interface RegistryComponent { name: string diff --git a/apps/web/app/import/page.tsx b/apps/web/app/import-old/page.tsx similarity index 100% rename from apps/web/app/import/page.tsx rename to apps/web/app/import-old/page.tsx diff --git a/apps/web/app/magic/console/page.client.tsx b/apps/web/app/magic/console/page.client.tsx index 7050dd8e..42d254c7 100644 --- a/apps/web/app/magic/console/page.client.tsx +++ b/apps/web/app/magic/console/page.client.tsx @@ -10,7 +10,6 @@ import { LoaderCircle, Copy, AlertTriangle, - MessageSquare, RefreshCw, } from "lucide-react" import { PLAN_LIMITS, PlanType } from "@/lib/config/subscription-plans" diff --git a/apps/web/app/publish/demo/page.tsx b/apps/web/app/publish-old/demo/page.tsx similarity index 97% rename from apps/web/app/publish/demo/page.tsx rename to apps/web/app/publish-old/demo/page.tsx index c6142d5c..75599eec 100644 --- a/apps/web/app/publish/demo/page.tsx +++ b/apps/web/app/publish-old/demo/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, Suspense } from "react" import { useSearchParams } from "next/navigation" import { useClerkSupabaseClient } from "@/lib/clerk" -import PublishComponentForm from "@/components/features/publish/publish-layout" +import PublishComponentForm from "@/components/features/publish-old/publish-layout" import fetchFileTextContent from "@/lib/utils/fetchFileTextContent" import { LoadingSpinnerPage } from "@/components/ui/loading-spinner" diff --git a/apps/web/app/publish-old/page.tsx b/apps/web/app/publish-old/page.tsx new file mode 100644 index 00000000..fd18d7fa --- /dev/null +++ b/apps/web/app/publish-old/page.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/nextjs" +import PublishComponentForm from "@/components/features/publish-old/publish-layout" +import { Metadata } from "next" + +import { Header } from "@/components/ui/header.client" + +export const metadata: Metadata = { + title: "Publish New Component | 21st.dev", +} + +export default function PublishPage() { + return ( + <> + +
+
+ +
+ + + + + + ) +} diff --git a/apps/web/app/publish/template/page.tsx b/apps/web/app/publish-old/template/page.tsx similarity index 95% rename from apps/web/app/publish/template/page.tsx rename to apps/web/app/publish-old/template/page.tsx index c5e84e14..98fd5510 100644 --- a/apps/web/app/publish/template/page.tsx +++ b/apps/web/app/publish-old/template/page.tsx @@ -1,6 +1,6 @@ import React from "react" import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/nextjs" -import { PublishTemplateForm } from "@/components/features/publish/template/publish-template-form" +import { PublishTemplateForm } from "@/components/features/publish-old/template/publish-template-form" import { Metadata } from "next" import { Header } from "@/components/ui/header.client" diff --git a/apps/web/app/publish/page.tsx b/apps/web/app/publish/page.tsx index 3266c055..c1000c78 100644 --- a/apps/web/app/publish/page.tsx +++ b/apps/web/app/publish/page.tsx @@ -1,26 +1,17 @@ -import React from "react" -import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/nextjs" -import PublishComponentForm from "@/components/features/publish/publish-layout" -import { Metadata } from "next" +import { redirect } from "next/navigation" +import { auth } from "@clerk/nextjs/server" +import { supabaseWithAdminAccess } from "@/lib/supabase" -import { Header } from "@/components/ui/header.client" - -export const metadata: Metadata = { - title: "Publish New Component | 21st.dev", -} - -export default function PublishPage() { - return ( - <> - -
-
- -
- - - - - - ) +export default async function Page() { + const { userId } = await auth() + if (!userId) redirect("/") + const { data: user } = await supabaseWithAdminAccess + .from("users") + .select("display_username,username") + .eq("id", userId) + .single() + if (!user) redirect("/") + const username = user.display_username || user.username + if (!username) redirect("/") + redirect(`/studio/${username}`) } diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index dbd0cb7d..9d2cb93e 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -20,7 +20,7 @@ export default async function sitemap(): Promise { .select("username, updated_at") const componentUrls = (components || []).map((component) => ({ - url: `${baseUrl}/${component.users?.username}/${component.component_slug}`, + url: `${baseUrl}/${component.users?.[0]?.username}/${component.component_slug}`, lastModified: component.updated_at, changeFrequency: "weekly" as const, priority: 0.8, diff --git a/apps/web/app/studio/[username]/page.client.tsx b/apps/web/app/studio/[username]/page.client.tsx new file mode 100644 index 00000000..69838a32 --- /dev/null +++ b/apps/web/app/studio/[username]/page.client.tsx @@ -0,0 +1,81 @@ +"use client" + +import { User } from "@/types/global" +import { StudioLayout } from "@/components/features/studio/studio-layout" +import { DemosTable } from "@/components/features/studio/ui/components-table" +import { Button } from "@/components/ui/button" +import { useRouter, usePathname } from "next/navigation" +import { createNewSandbox } from "@/components/features/studio/sandbox/api" +import { useState } from "react" +import { Spinner } from "@/components/icons/spinner" +import { cn } from "@/lib/utils" +import { ExtendedDemoWithComponent } from "@/lib/utils/transformData" + +interface StudioUsernameClientProps { + user: User + demos: ExtendedDemoWithComponent[] + isAdmin: boolean + isOwnProfile: boolean +} + +export function StudioUsernameClient({ + user, + demos, + isAdmin, + isOwnProfile, +}: StudioUsernameClientProps) { + const router = useRouter() + const pathname = usePathname() + const [isCreating, setIsCreating] = useState(false) + + const handleCreateNewSandbox = async () => { + try { + setIsCreating(true) + const { sandboxId } = await createNewSandbox() + router.push(`${pathname}/sandbox/${sandboxId}`) + } catch (error) { + console.error("Failed to create sandbox:", error) + } finally { + setIsCreating(false) + } + } + + const handleOpenSandbox = (item: ExtendedDemoWithComponent) => { + router.push(`${pathname}/sandbox/${item.id}`) + } + + return ( + +
+
+

Components

+ {(isOwnProfile || isAdmin) && ( + + )} +
+ +
+ +
+
+
+ ) +} diff --git a/apps/web/app/studio/[username]/page.tsx b/apps/web/app/studio/[username]/page.tsx new file mode 100644 index 00000000..3b8a1cc1 --- /dev/null +++ b/apps/web/app/studio/[username]/page.tsx @@ -0,0 +1,171 @@ +import { redirect } from "next/navigation" +import { supabaseWithAdminAccess } from "@/lib/supabase" +import { auth } from "@clerk/nextjs/server" +import { unstable_cache } from "next/cache" +import { getUserData } from "@/lib/queries" +import { StudioUsernameClient } from "./page.client" +import { + transformDemoResult, + ExtendedDemoWithComponent, +} from "@/lib/utils/transformData" +import ShortUUID from "short-uuid" + +// Get user data by username +const getCachedUser = unstable_cache( + async (username: string) => { + const { data: user } = await getUserData(supabaseWithAdminAccess, username) + return user + }, + ["user-data"], + { + revalidate: 30, + tags: ["user-data"], + }, +) + +// Get demos by user ID +const getCachedUserDemos = unstable_cache( + async (userId: string) => { + const { data: demos, error } = await supabaseWithAdminAccess.rpc( + "get_user_profile_demo_list_v2", + { + p_user_id: userId, + }, + ) + + if (error) { + console.error("Error fetching user demos:", error) + return [] + } + + return demos ? demos.map(transformDemoResult) : [] + }, + ["user-demos"], + { + revalidate: 30, + tags: ["user-demos"], + }, +) + +// Get sandboxes by user ID +const getCachedUserSandboxes = unstable_cache( + async (userId: string) => { + const { data: sandboxesData, error } = await supabaseWithAdminAccess + .from("sandboxes") + .select("*") + .eq("user_id", userId) + + if (error) { + console.error("Error fetching user sandboxes:", error) + return [] + } + + // Transform sandboxes to ExtendedDemoWithComponent format + const shortUUID = ShortUUID() + return (sandboxesData || []).map( + (sandbox): ExtendedDemoWithComponent => ({ + id: sandbox.id, + sandbox_id: shortUUID.fromUUID(sandbox.id), // Use short UUID if needed, or original ID if consistent + name: sandbox.name || "Untitled Sandbox", + created_at: sandbox.created_at, + updated_at: sandbox.updated_at, + submission_status: "draft", // Mark as draft + is_private: true, // Sandboxes are typically private initially + // Add default/null values for other required fields + demo_slug: sandbox.id, // Use sandbox id as a placeholder slug + preview_url: null, + video_url: null, + // @ts-ignore + component: undefined, // No linked component initially + // @ts-ignore + user: null, // User data can be added if needed, but might not be necessary for table display + component_user: null, + total_count: 0, + view_count: 0, + bookmarks_count: 0, + // @ts-ignore + bundle_url: null, + // @ts-ignore + moderators_feedback: null, + }), + ) + }, + ["user-sandboxes"], + { + revalidate: 30, + tags: ["user-sandboxes"], + }, +) + +export async function generateMetadata({ + params, +}: { + params: Promise<{ username: string }> +}) { + const resolvedParams = await params + const user = await getCachedUser(resolvedParams.username) + + if (!user) { + return { + title: "User Not Found | Studio", + } + } + + return { + title: `${user.display_name || user.name || user.username}'s Studio | 21st.dev`, + description: `Manage components by ${user.display_name || user.name || user.username} on 21st.dev`, + } +} + +export default async function StudioUsernamePage({ + params, +}: { + params: Promise<{ username: string }> +}) { + // Verify user is authenticated + const { userId } = await auth() + if (!userId) { + redirect("/sign-in") + } + + // Get the resolved params + const resolvedParams = await params + + // Get the target user from the URL + const user = await getCachedUser(resolvedParams.username) + if (!user) { + redirect("/studio") + } + + // Fetch demos and sandboxes + const demos = await getCachedUserDemos(user.id) + const sandboxes = await getCachedUserSandboxes(user.id) + + // Combine demos and sandboxes into a single list + const combinedItems = [...demos, ...sandboxes] + + // Verify logged in user has access to this user's components + // Admin can access any user's components, users can only access their own + const { data: currentUser } = await supabaseWithAdminAccess + .from("users") + .select("is_admin") + .eq("id", userId) + .single() + + const isAdmin = currentUser?.is_admin || false + const isOwnProfile = userId === user.id + + if (!isAdmin && !isOwnProfile) { + redirect("/studio") + } + + // Pass everything to the client component + return ( + + ) +} diff --git a/apps/web/app/studio/[username]/sandbox/[sandboxId]/page.client.tsx b/apps/web/app/studio/[username]/sandbox/[sandboxId]/page.client.tsx new file mode 100644 index 00000000..ba580971 --- /dev/null +++ b/apps/web/app/studio/[username]/sandbox/[sandboxId]/page.client.tsx @@ -0,0 +1,444 @@ +"use client" + +import { useEffect, useState, Suspense } from "react" +import { useParams, useRouter, usePathname } from "next/navigation" +import { + ResizableHandle, + ResizablePanelGroup, + ResizablePanel, +} from "@/components/ui/resizable" +import { FileExplorer } from "@/components/features/studio/sandbox/components/file-explorer" +import { PreviewPane } from "@/components/features/studio/sandbox/components/preview-pane" +import { Spinner } from "@/components/icons/spinner" +import { useSandbox } from "@/components/features/studio/sandbox/hooks/use-sandbox" +import { + useFileSystem, + type FileEntry, +} from "@/components/features/studio/sandbox/hooks/use-file-system" +import { Button } from "@/components/ui/button" +import { Icons } from "@/components/icons" +import { + XCircle, + RefreshCw, + PanelRightOpen, + PanelRightClose, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { Skeleton } from "@/components/ui/skeleton" + +function PublishClientPageContent() { + const params = useParams() + const router = useRouter() + const pathname = usePathname() + const sandboxId = params.sandboxId as string + const [selectedEntry, setSelectedEntry] = useState(null) + const [code, setCode] = useState("") + const [isRegenerating, setIsRegenerating] = useState(false) + const [showPreview, setShowPreview] = useState(true) + const [iframeKey, setIframeKey] = useState(0) + const [isNavigating, setIsNavigating] = useState(false) + + const { + sandboxRef, + previewURL, + isSandboxLoading, + sandboxConnectionHash, + reconnectSandbox, + missingDependencyInfo, + clearMissingDependencyInfo, + connectedShellId, + } = useSandbox({ sandboxId }) + + const { + files, + isTreeLoading, + isFileLoading, + advancedView, + toggleAdvancedView, + loadRootDirectory, + loadFileContent, + saveFileContent, + createFile, + deleteEntry, + createDirectory, + renameEntry, + addDependencyToPackageJson, + generateRegistry, + } = useFileSystem({ + sandboxRef, + reconnectSandbox, + sandboxConnectionHash, + connectedShellId, + }) + + useEffect(() => { + // Renamed function for clarity + const findAndSelectFirstUiFile = async () => { + console.log("Attempting to find initial UI file...") // Log: Start + console.log( + "isTreeLoading:", + isTreeLoading, + "files.length:", + files.length, + "selectedEntry:", + selectedEntry, + ) // Log: State check + if (!isTreeLoading && files.length > 0 && !selectedEntry) { + console.log("Files list:", JSON.stringify(files, null, 2)) // Log: Full file list (can be verbose) + + const findFirstUiFile = (entries: FileEntry[]): FileEntry | null => { + for (const entry of entries) { + // Check if path starts with /src/components/ui AND is not a directory itself + if (entry.path.startsWith("/src/components/ui")) { + if (entry.type === "file") { + console.log("Found potential UI file:", entry.path) // Log: Potential find + return entry + } else if (entry.type === "dir" && entry.children) { + // Recurse only if it's a directory within the target path + const fileInChildren = findFirstUiFile(entry.children) + if (fileInChildren) return fileInChildren + } + } + // Also check children even if parent doesn't match, path might be nested deeper + else if (entry.type === "dir" && entry.children) { + const fileInChildren = findFirstUiFile(entry.children) + if (fileInChildren) return fileInChildren + } + } + return null + } + + const firstUiFile = findFirstUiFile(files) + console.log("Result of findFirstUiFile:", firstUiFile) // Log: Result + + if (firstUiFile) { + console.log("Setting selected entry:", firstUiFile.path) // Log: Setting state + setSelectedEntry(firstUiFile) + // Explicitly load content after setting the entry + try { + console.log("Calling loadFileContent for:", firstUiFile.path) // Log: Loading content + const content = await loadFileContent(firstUiFile.path) + console.log("Content loaded successfully.") // Log: Load success + setCode(content) + } catch (error) { + // Handle error if initial load fails + console.error("Failed to load initial file content:", error) // Log: Load error + setCode("") + setSelectedEntry(null) + } + } else { + console.log("No initial UI file found in /src/components/ui") // Log: Not found + } + } + } + + findAndSelectFirstUiFile() + }, [files, isTreeLoading, selectedEntry, loadFileContent]) // Add loadFileContent to dependencies + + useEffect(() => { + if (missingDependencyInfo) { + addDependencyToPackageJson( + missingDependencyInfo.packageName, + missingDependencyInfo.latestVersion, + ) + clearMissingDependencyInfo() + } + }, [missingDependencyInfo]) + + useEffect(() => { + if ( + !sandboxRef.current || + !selectedEntry || + selectedEntry.type !== "file" + ) { + setCode("") + return + } + + const loadContent = async () => { + try { + const content = await loadFileContent(selectedEntry.path) + setCode(content) + } catch (error) { + setCode("") + setSelectedEntry(null) + } + } + + loadContent() + }, [sandboxRef, selectedEntry, isSandboxLoading]) + + const handleCodeChange = (value: string) => { + setCode(value) + if (!sandboxRef.current || !selectedEntry || selectedEntry.type !== "file") + return + saveFileContent(selectedEntry.path, value) + } + + const handleCreateFile = async (filePath: string) => { + try { + await createFile(filePath) + await loadRootDirectory() + const newEntry: FileEntry = { + name: filePath.split("/").pop() || filePath, + path: filePath.startsWith("/") ? filePath : `/${filePath}`, + type: "file", + isSymlink: false, + } + setSelectedEntry(newEntry) + } catch (error) { + // Error is handled in the hook + } + } + + const handleDeleteEntry = async (entryPath: string) => { + try { + await deleteEntry(entryPath) + if (selectedEntry?.path === entryPath) { + setSelectedEntry(null) + setCode("") + } + await loadRootDirectory() + } catch (error) { + // Error is handled in the hook + } + } + + const handleRenameEntry = async (oldPath: string, newName: string) => { + try { + const newPath = await renameEntry(oldPath, newName) + + // Update selected entry if it was the renamed one + if (selectedEntry?.path === oldPath) { + setSelectedEntry({ + ...selectedEntry, + path: newPath, + name: newName, + }) + } + + return newPath + } catch (error) { + // Error is handled in the hook + return oldPath + } + } + + const handleGenerateRegistry = async () => { + console.log("Starting registry generation...") + // setIsRegenerating(true) + generateRegistry() + } + + const handleReset = () => { + window.location.reload() + } + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!sandboxRef.current) { + if (e.key === "Escape") { + router.back() + } else if (e.key === "Enter") { + handleReset() + } + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [router, sandboxRef]) + + const handleRefreshPreview = () => { + setIframeKey((prev) => prev + 1) + } + + const handleTogglePreview = () => { + setShowPreview((prev) => !prev) + } + + const handleNextStep = () => { + setIsNavigating(true) + router.push(`${pathname}/publish`) + } + + if (isSandboxLoading) { + // Skeleton Loader for Sandbox UI (3-panel layout, no header skeleton) + return ( +
+ {" "} + {/* Added top padding for header */} + {/* Skeleton Resizable Panel Group - Represents the main content area below the header */} +
+ {/* Skeleton File Explorer Panel (Left) */} +
+ + +
+ + +
+ + +
+ + {/* Skeleton Resizable Handle 1 */} +
+ + {/* Skeleton Editor Panel (Middle) */} +
+ {/* Skeleton Editor Content */} +
+ {[...Array(15)].map((_, i) => ( + + ))} +
+
+ + {/* Skeleton Resizable Handle 2 */} +
+ + {/* Skeleton Preview Panel (Right) */} +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+
+
+ {/* Skeleton Bottom Right Controls */} +
+ + +
+
+ ) + } + + if (!sandboxRef.current) { + return ( +
+
+ +

Failed to initialize sandbox

+
+ + +
+
+
+ ) + } + + return ( +
+ {/* Header is rendered in page.tsx */} + + + + + + + + + + + + {/* Bottom right preview controls */} +
+ {showPreview && ( + + )} + +
+
+ ) +} + +export default function PublishPage() { + return ( + Loading project...}> + + + ) +} diff --git a/apps/web/app/studio/[username]/sandbox/[sandboxId]/page.tsx b/apps/web/app/studio/[username]/sandbox/[sandboxId]/page.tsx new file mode 100644 index 00000000..eb2bb2f3 --- /dev/null +++ b/apps/web/app/studio/[username]/sandbox/[sandboxId]/page.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useParams } from "next/navigation" +import { SandboxHeader } from "@/components/features/studio/sandbox/components/sandbox-header" +import { useRouter, usePathname } from "next/navigation" +import { useState } from "react" +import { useSandbox } from "@/components/features/studio/sandbox/hooks/use-sandbox" +import PageClient from "./page.client" + +export default function Page() { + const { username, sandboxId } = useParams() as { + username: string + sandboxId: string + } + const router = useRouter() + const pathname = usePathname() + const [isNextLoading, setIsNextLoading] = useState(false) + // Fetch sandbox metadata for header + const { serverSandbox } = useSandbox({ sandboxId }) + + const handleNext = () => { + setIsNextLoading(true) + router.push(`${pathname}/publish`) + } + + return ( + <> + + + + ) +} diff --git a/apps/web/app/studio/[username]/sandbox/[sandboxId]/publish/page.client.tsx b/apps/web/app/studio/[username]/sandbox/[sandboxId]/publish/page.client.tsx new file mode 100644 index 00000000..2037aaf5 --- /dev/null +++ b/apps/web/app/studio/[username]/sandbox/[sandboxId]/publish/page.client.tsx @@ -0,0 +1,508 @@ +"use client" + +import React, { useState, useCallback, useEffect, useRef } from "react" +import { useParams } from "next/navigation" +import { useForm } from "react-hook-form" +import { Trash2 } from "lucide-react" +import { useUser } from "@clerk/nextjs" +import { toast } from "sonner" +import { zodResolver } from "@hookform/resolvers/zod" +import { debounce } from "lodash" + +import { Form } from "@/components/ui/form" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { LoadingDialog } from "@/components/ui/loading-dialog" +import { Skeleton } from "@/components/ui/skeleton" + +import { ComponentForm } from "@/components/features/studio/publish/components/forms/component-form" +import { DemoDetailsForm } from "@/components/features/studio/publish/components/forms/demo-form" +import { SuccessDialog } from "@/components/features/publish-old/components/success-dialog" +import { + FormData, + formSchema, +} from "@/components/features/studio/publish/config/utils" +import { useSubmitComponent } from "@/components/features/studio/publish/hooks/use-submit-component" +import { useComponentData } from "@/components/features/studio/publish/hooks/use-component-data" + +import { cn } from "@/lib/utils" +import { useSandbox } from "@/components/features/studio/sandbox/hooks/use-sandbox" +import { useFileSystem } from "@/components/features/studio/sandbox/hooks/use-file-system" +import { usePublishAs } from "@/components/features/publish-old/hooks/use-publish-as" +import { editSandbox } from "@/components/features/studio/sandbox/api" + +type FormStep = "detailedForm" + +type ParsedCodeData = { + componentNames: string[] + dependencies?: Record + demoDependencies?: Record +} + +const PublishPage = ({ + submitHandlerRef, +}: { + submitHandlerRef?: React.MutableRefObject<(() => void) | null> +}) => { + const params = useParams() + const sandboxId = params.sandboxId as string + const { + previewURL, + sandboxRef, + reconnectSandbox, + connectedShellId, + sandboxConnectionHash, + serverSandbox, + } = useSandbox({ + sandboxId, + }) + + // Fetch component data if sandbox is linked to a component + const { isLoading: isComponentDataLoading, formData: componentFormData } = + useComponentData(serverSandbox?.component_id) + + const { generateRegistry, bundleDemo, updateComponentNameAndImport } = + useFileSystem({ + sandboxRef: sandboxRef, + reconnectSandbox: reconnectSandbox, + sandboxConnectionHash: sandboxConnectionHash, + connectedShellId, + }) + const { user } = useUser() + const { isLoaded: isClerkUserLoaded } = useUser() + + const [formStep] = useState("detailedForm") + const [openAccordion, setOpenAccordion] = useState([ + "component-info", + "demo-0", + ]) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: serverSandbox?.name || "", + component_slug: "", + registry: "ui", + description: "", + license: "mit", + website_url: "", + is_public: false, + publish_as_username: user?.username ?? undefined, + is_paid: false, + price: 0, + unknown_dependencies: [], + direct_registry_dependencies: [], + code: `// Mock component code for ${sandboxId}\nexport default function MockComponent() { return
Hello
; }`, + demos: [ + { + name: "Default Demo", + demo_code: `// Mock demo code for ${sandboxId}\nimport MockComponent from './component';\nexport default function Demo() { return ; }`, + demo_slug: "default", + tags: [], + preview_image_data_url: "", + preview_image_file: undefined, + preview_video_data_url: "", + preview_video_file: undefined, + demo_direct_registry_dependencies: [], + demo_dependencies: {}, + }, + ], + }, + }) + + // Prefill form with component data if available + useEffect(() => { + if (componentFormData) { + const { code, demos, ...rest } = componentFormData + form.reset({ + ...rest, + code: form.getValues("code"), + demos: (demos || []).map((demo, i) => ({ + ...demo, + demo_code: form.getValues(`demos.${i}.demo_code`) || demo.demo_code, + })), + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [componentFormData]) + + const publishAsUsername = form.watch("publish_as_username") + const { user: publishAsUser, isLoading: isPublishAsLoading } = usePublishAs({ + username: publishAsUsername ?? user?.username ?? "", + }) + + useEffect(() => { + if (form.getValues("publish_as_username") === undefined && user?.username) { + form.setValue("publish_as_username", user.username) + } + }, [user?.username, form]) + + const { + isSubmitting, + isLoadingDialogOpen, + publishProgress, + isSuccessDialogOpen, + createdDemoSlug, + submitComponent, + setIsSuccessDialogOpen, + } = useSubmitComponent() + + const prevNameRef = useRef("") + + const debouncedUpdateName = useRef( + debounce(async (newName: string) => { + if (!sandboxId) return + + try { + if (newName !== prevNameRef.current) { + console.log( + `Updating sandbox name from "${prevNameRef.current}" to "${newName}"`, + ) + prevNameRef.current = newName + + await editSandbox(sandboxId, { name: newName }) + console.log("Updated sandbox name in database") + } + } catch (error) { + console.error("Failed to update sandbox name in database:", error) + } + }, 800), + ).current + + useEffect(() => { + prevNameRef.current = form.getValues("name") || serverSandbox?.name || "" + }, [serverSandbox?.name, form, componentFormData]) + + useEffect(() => { + const subscription = form.watch((value, { name: fieldName }) => { + if (fieldName === "name" && typeof value.name === "string") { + const newName = value.name + if (newName !== prevNameRef.current) { + console.log(`Name field changed, scheduling update to: "${newName}"`) + debouncedUpdateName(newName) + } + } + }) + + return () => { + debouncedUpdateName.cancel() + subscription.unsubscribe() + } + }, [form, debouncedUpdateName]) + + const handleAccordionChange = useCallback((value: string[]) => { + setOpenAccordion(value) + }, []) + + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault() + + if (!isClerkUserLoaded || isPublishAsLoading) { + console.warn("User data is still loading. Aborting submission.") + toast.info("User data is loading, please wait...") + return + } + + let finalPublishUser: { id: string; username?: string } | null = null + + if (publishAsUser?.id) { + finalPublishUser = { + id: publishAsUser.id, + username: publishAsUser.username || undefined, + } + } else if (user?.id) { + finalPublishUser = { id: user.id, username: user.username || undefined } + } + + if (!finalPublishUser) { + console.error("Cannot determine user to publish as.") + toast.error( + "Cannot determine user to publish as. Please ensure you are logged in.", + ) + return + } + + const currentSandbox = serverSandbox + + form.handleSubmit( + (formData) => { + console.log("Form data is valid:", formData) + + if (!currentSandbox?.id) { + console.error("Sandbox data is missing.", currentSandbox) + toast.error("Sandbox data is missing. Cannot submit.") + return + } + + const data = { + ...formData, + website_url: formData.website_url || "", + } + + submitComponent({ + data, + publishAsUser: finalPublishUser, + generateRegistry, + bundleDemo, + updateComponentNameAndImport, + sandboxId: currentSandbox.id, + onSuccess: () => { + reconnectSandbox() + }, + }) + }, + (errors) => { + console.error("Form validation errors:", errors) + toast.error("Please fill all the required fields") + }, + )(event) + } + + const watchedComponentFields = form.watch([ + "description", + "name", + "component_slug", + ]) + const isComponentInfoComplete = useCallback(() => { + const [description, name, component_slug] = watchedComponentFields + return !!description && !!name && !!component_slug + }, [watchedComponentFields]) + + const handleGoToComponent = useCallback(() => { + let finalPublishUser: { id: string; username?: string } | null = null + if (publishAsUser?.id) { + finalPublishUser = { + id: publishAsUser.id, + username: publishAsUser.username || undefined, + } + } else if (user?.id) { + finalPublishUser = { id: user.id, username: user.username || undefined } + } + + const username = finalPublishUser?.username + const slug = form.getValues("component_slug") + const demoSlug = createdDemoSlug || "default" + if (username && slug) { + window.location.href = `/${username}/${slug}/${demoSlug}` + console.log(`Redirecting to /${username}/${slug}/${demoSlug}`) + } else { + console.warn("Could not determine redirect path.") + } + setIsSuccessDialogOpen(false) + }, [form, createdDemoSlug, setIsSuccessDialogOpen, publishAsUser, user]) + + const handleAddAnother = () => { + form.reset() + setIsSuccessDialogOpen(false) + setOpenAccordion(["component-info", "demo-0"]) + } + + const isDemoComplete = (demo: any) => + demo.name && demo.tags?.length > 0 && demo.preview_image_data_url + + submitHandlerRef!.current = handleSubmit + + if (isComponentDataLoading || (!serverSandbox?.id && !componentFormData)) { + return ( +
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ +
+
+
+ ) + } + + return ( + <> +
+
+
+
+
+
+ + + +
+ Component info + + +
+
+ +
+ +
+
+
+ + {form.getValues().demos?.map((demo, index) => ( + + +
+
+
+ {index === 0 && !demo.name + ? "Default Demo" + : demo.name || `Demo ${index + 1}`} +
+ + +
+ {form.getValues().demos.length > 1 && ( + + )} +
+
+ +
+ +
+
+
+ ))} +
+
+
+
+ +