From 6948e8a3be0513e956d1e0b8e8599f5b4417df6e Mon Sep 17 00:00:00 2001 From: Ekkasit Samathimankong Date: Fri, 10 Apr 2026 18:27:45 +1200 Subject: [PATCH 01/13] feat(seed-studio): add AI-powered experience creator with chat-first UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone Next.js app where the content team creates themed experiences via chat. Claude CLI generates sections (video, text, bible quotes, Q&A, quiz) and AI arranges per-platform ordering (web vs mobile). Publishes directly to Strapi with nested video relation patching. Key components: - SSE streaming API route (spawn claude CLI) for real-time chat - Split-screen UI: chat panel (40%) + live preview panel (60%) - 8 section type renderers with platform toggle - Strapi custom endpoints for video search, publish, and catalog stats - Simple password auth with session cookie 🤖 Generated with Claude Opus 4.6 via Claude Code(https://claude.com/claude-code) + Compound Engineering v2.44.0 Co-Authored-By: Claude Opus 4.6 (1M context, extended thinking) --- .../seed-studio/controllers/seed-studio.ts | 100 +++++ .../src/api/seed-studio/routes/seed-studio.ts | 34 ++ .../api/seed-studio/services/seed-studio.ts | 228 ++++++++++ apps/seed-studio/next.config.mjs | 21 + apps/seed-studio/package.json | 30 ++ apps/seed-studio/postcss.config.mjs | 8 + apps/seed-studio/src/app/actions/publish.ts | 16 + apps/seed-studio/src/app/api/auth/route.ts | 28 ++ apps/seed-studio/src/app/api/chat/route.ts | 152 +++++++ apps/seed-studio/src/app/globals.css | 34 ++ apps/seed-studio/src/app/layout.tsx | 26 ++ apps/seed-studio/src/app/login/login-form.tsx | 72 ++++ apps/seed-studio/src/app/login/page.tsx | 19 + apps/seed-studio/src/app/page.tsx | 5 + .../src/components/chat/ChatInput.tsx | 85 ++++ .../src/components/chat/ChatMessage.tsx | 42 ++ .../src/components/chat/ChatPanel.tsx | 163 ++++++++ .../src/components/chat/SuggestionChips.tsx | 32 ++ .../src/components/preview/PlatformToggle.tsx | 45 ++ .../src/components/preview/PreviewPanel.tsx | 77 ++++ .../src/components/preview/SectionCard.tsx | 89 ++++ .../components/preview/SectionRenderer.tsx | 37 ++ .../preview/sections/BibleQuotesPreview.tsx | 43 ++ .../preview/sections/ContainerPreview.tsx | 49 +++ .../preview/sections/QuizButtonPreview.tsx | 26 ++ .../sections/RelatedQuestionsPreview.tsx | 61 +++ .../preview/sections/TextSectionPreview.tsx | 29 ++ .../preview/sections/VideoCarouselPreview.tsx | 54 +++ .../preview/sections/VideoHeroPreview.tsx | 53 +++ .../preview/sections/VideoSectionPreview.tsx | 45 ++ .../src/components/publish/PublishButton.tsx | 48 +++ .../src/components/publish/PublishDialog.tsx | 215 ++++++++++ apps/seed-studio/src/components/studio.tsx | 72 ++++ .../src/lib/ai/experience-schema.ts | 131 ++++++ apps/seed-studio/src/lib/chat/use-chat.ts | 184 ++++++++ apps/seed-studio/src/lib/cn.ts | 6 + apps/seed-studio/src/lib/strapi-client.ts | 122 ++++++ apps/seed-studio/src/middleware.ts | 29 ++ apps/seed-studio/tsconfig.json | 32 ++ .../2026-04-09-seed-studio-requirements.md | 75 ++++ ...-seed-studio-ai-experience-creator-plan.md | 394 ++++++++++++++++++ pnpm-lock.yaml | 126 +++--- 42 files changed, 3082 insertions(+), 55 deletions(-) create mode 100644 apps/cms/src/api/seed-studio/controllers/seed-studio.ts create mode 100644 apps/cms/src/api/seed-studio/routes/seed-studio.ts create mode 100644 apps/cms/src/api/seed-studio/services/seed-studio.ts create mode 100644 apps/seed-studio/next.config.mjs create mode 100644 apps/seed-studio/package.json create mode 100644 apps/seed-studio/postcss.config.mjs create mode 100644 apps/seed-studio/src/app/actions/publish.ts create mode 100644 apps/seed-studio/src/app/api/auth/route.ts create mode 100644 apps/seed-studio/src/app/api/chat/route.ts create mode 100644 apps/seed-studio/src/app/globals.css create mode 100644 apps/seed-studio/src/app/layout.tsx create mode 100644 apps/seed-studio/src/app/login/login-form.tsx create mode 100644 apps/seed-studio/src/app/login/page.tsx create mode 100644 apps/seed-studio/src/app/page.tsx create mode 100644 apps/seed-studio/src/components/chat/ChatInput.tsx create mode 100644 apps/seed-studio/src/components/chat/ChatMessage.tsx create mode 100644 apps/seed-studio/src/components/chat/ChatPanel.tsx create mode 100644 apps/seed-studio/src/components/chat/SuggestionChips.tsx create mode 100644 apps/seed-studio/src/components/preview/PlatformToggle.tsx create mode 100644 apps/seed-studio/src/components/preview/PreviewPanel.tsx create mode 100644 apps/seed-studio/src/components/preview/SectionCard.tsx create mode 100644 apps/seed-studio/src/components/preview/SectionRenderer.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/BibleQuotesPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/ContainerPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/QuizButtonPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/RelatedQuestionsPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/TextSectionPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/VideoCarouselPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/VideoHeroPreview.tsx create mode 100644 apps/seed-studio/src/components/preview/sections/VideoSectionPreview.tsx create mode 100644 apps/seed-studio/src/components/publish/PublishButton.tsx create mode 100644 apps/seed-studio/src/components/publish/PublishDialog.tsx create mode 100644 apps/seed-studio/src/components/studio.tsx create mode 100644 apps/seed-studio/src/lib/ai/experience-schema.ts create mode 100644 apps/seed-studio/src/lib/chat/use-chat.ts create mode 100644 apps/seed-studio/src/lib/cn.ts create mode 100644 apps/seed-studio/src/lib/strapi-client.ts create mode 100644 apps/seed-studio/src/middleware.ts create mode 100644 apps/seed-studio/tsconfig.json create mode 100644 docs/brainstorms/2026-04-09-seed-studio-requirements.md create mode 100644 docs/plans/2026-04-09-003-feat-seed-studio-ai-experience-creator-plan.md diff --git a/apps/cms/src/api/seed-studio/controllers/seed-studio.ts b/apps/cms/src/api/seed-studio/controllers/seed-studio.ts new file mode 100644 index 000000000..2e2a22701 --- /dev/null +++ b/apps/cms/src/api/seed-studio/controllers/seed-studio.ts @@ -0,0 +1,100 @@ +import type { Core } from "@strapi/strapi" + +type StrapiContext = { + status: number + body: unknown + query: Record + request: { + header: Record + body?: Record + } +} + +function validateToken(ctx: StrapiContext): boolean { + const token = ctx.request.header["x-seed-studio-token"] + const expected = process.env.SEED_STUDIO_API_TOKEN + if (!expected || token !== expected) { + ctx.status = 401 + ctx.body = { error: "Invalid or missing X-Seed-Studio-Token" } + return false + } + return true +} + +export default ({ strapi }: { strapi: Core.Strapi }) => ({ + async searchVideos(ctx: StrapiContext) { + if (!validateToken(ctx)) return + + const body = ctx.request.body ?? {} + const query = typeof body.query === "string" ? body.query : "" + const tags = + Array.isArray(body.tags) && + body.tags.every((t: unknown) => typeof t === "string") + ? (body.tags as string[]) + : undefined + const locale = typeof body.locale === "string" ? body.locale : "en" + + if (!query) { + ctx.status = 400 + ctx.body = { error: "Missing required field: query" } + return + } + + const service = strapi.service("api::seed-studio.seed-studio") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const results = await (service as any).searchVideos(query, tags, locale) + + ctx.status = 200 + ctx.body = { videos: results } + }, + + async publishExperience(ctx: StrapiContext) { + if (!validateToken(ctx)) return + + const body = ctx.request.body ?? {} + const { title, slug, metaDescription, blocks, platformOrdering, locale } = + body as Record + + if (typeof title !== "string" || !title) { + ctx.status = 400 + ctx.body = { error: "Missing required field: title" } + return + } + if (typeof slug !== "string" || !slug) { + ctx.status = 400 + ctx.body = { error: "Missing required field: slug" } + return + } + if (!Array.isArray(blocks) || blocks.length === 0) { + ctx.status = 400 + ctx.body = { error: "Missing required field: blocks (non-empty array)" } + return + } + + const service = strapi.service("api::seed-studio.seed-studio") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (service as any).publishExperience({ + title, + slug, + metaDescription: + typeof metaDescription === "string" ? metaDescription : undefined, + blocks, + platformOrdering: platformOrdering ?? undefined, + locale: typeof locale === "string" ? locale : "en", + }) + + ctx.status = 201 + ctx.body = result + }, + + async videoCatalogStats(ctx: StrapiContext) { + if (!validateToken(ctx)) return + + const service = strapi.service("api::seed-studio.seed-studio") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stats = await (service as any).getVideoCatalogStats() + + ctx.status = 200 + ctx.body = stats + }, +}) diff --git a/apps/cms/src/api/seed-studio/routes/seed-studio.ts b/apps/cms/src/api/seed-studio/routes/seed-studio.ts new file mode 100644 index 000000000..bd2d85062 --- /dev/null +++ b/apps/cms/src/api/seed-studio/routes/seed-studio.ts @@ -0,0 +1,34 @@ +export default { + routes: [ + { + method: "POST", + path: "/seed-studio/search-videos", + handler: "seed-studio.searchVideos", + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + { + method: "POST", + path: "/seed-studio/publish-experience", + handler: "seed-studio.publishExperience", + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + { + method: "GET", + path: "/seed-studio/video-catalog-stats", + handler: "seed-studio.videoCatalogStats", + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + ], +} diff --git a/apps/cms/src/api/seed-studio/services/seed-studio.ts b/apps/cms/src/api/seed-studio/services/seed-studio.ts new file mode 100644 index 000000000..ad3b106d6 --- /dev/null +++ b/apps/cms/src/api/seed-studio/services/seed-studio.ts @@ -0,0 +1,228 @@ +import type { Core } from "@strapi/strapi" +import { + DEFAULT_LOCALE, + getExperienceService, + patchNestedVideoRelations, +} from "../../../bootstrap/seed-utils" + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KnexInstance = any + +type VideoSearchResult = { + id: number + documentId: string + title: string + slug: string + description: string | null + streamingUrl: string | null + thumbnailUrl: string | null +} + +type PublishExperienceInput = { + title: string + slug: string + metaDescription?: string + blocks: Record[] + platformOrdering?: unknown + locale?: string +} + +type PublishExperienceResult = { + created: boolean + relationsPatched: boolean + documentId: string + slug: string +} + +type VideoCatalogStats = { + totalVideos: number + labels: string[] + locales: string[] +} + +export default ({ strapi }: { strapi: Core.Strapi }) => ({ + /** + * Search published videos by title/description/slug with ILIKE matching. + * Optionally filter by label tags. Returns max 20 results. + */ + async searchVideos( + query: string, + tags?: string[], + locale: string = DEFAULT_LOCALE, + ): Promise { + const knex: KnexInstance = (strapi.db as KnexInstance).connection + const pattern = `%${query}%` + + let builder = knex("videos") + .select( + "id", + "document_id as documentId", + "title", + "slug", + "description", + "streaming_url as streamingUrl", + "thumbnail_url as thumbnailUrl", + ) + .where("locale", locale) + .whereNotNull("published_at") + .andWhere(function (this: KnexInstance) { + this.where("title", "ILIKE", pattern) + .orWhere("description", "ILIKE", pattern) + .orWhere("slug", "ILIKE", pattern) + }) + + if (tags && tags.length > 0) { + builder = builder.whereIn("label", tags) + } + + const rows: VideoSearchResult[] = await builder.orderBy("title").limit(20) + + return rows + }, + + /** + * Create (or re-create) an Experience with blocks, then patch nested + * video relations that Strapi v5 Document Service silently drops. + * + * Follows the same delete-then-create pattern as seed-easter. + */ + async publishExperience( + data: PublishExperienceInput, + ): Promise { + const locale = data.locale ?? DEFAULT_LOCALE + const experienceService = getExperienceService(strapi) + + // Delete existing experience with the same slug for clean re-creation + const existing = await experienceService.findFirst({ + locale, + status: "published", + filters: { slug: data.slug }, + }) + + if (existing) { + await experienceService.delete({ documentId: existing.documentId }) + strapi.log.info( + `[seed-studio] Deleted existing Experience "${data.slug}" for re-creation.`, + ) + } + + // Create via Document Service + const created = await experienceService.create({ + locale, + status: "published", + data: { + slug: data.slug, + title: data.title, + metaDescription: data.metaDescription, + pathSegment: data.slug, + blocks: data.blocks, + ...(data.platformOrdering != null + ? { platformOrdering: data.platformOrdering } + : {}), + }, + }) + + strapi.log.info( + `[seed-studio] Created Experience "${data.slug}" (documentId=${created.documentId}).`, + ) + + // Build videoMap from blocks: collect all sections.video components + // with a sectionKey and numeric video id for relation patching. + const videoMap = new Map() + collectVideoRelations(data.blocks, videoMap) + + let relationsPatched = false + if (videoMap.size > 0) { + await patchNestedVideoRelations(strapi, videoMap) + relationsPatched = true + strapi.log.info( + `[seed-studio] Patched ${videoMap.size} video relation(s) for "${data.slug}".`, + ) + } + + return { + created: true, + relationsPatched, + documentId: created.documentId, + slug: data.slug, + } + }, + + /** + * Return catalog overview: total published videos, distinct labels, and locales. + */ + async getVideoCatalogStats(): Promise { + const knex: KnexInstance = (strapi.db as KnexInstance).connection + + const [countResult]: [{ count: string }] = await knex("videos") + .whereNotNull("published_at") + .count("id as count") + + const labelRows: { label: string }[] = await knex("videos") + .distinct("label") + .whereNotNull("published_at") + .whereNotNull("label") + .where("label", "!=", "") + .orderBy("label") + + const localeRows: { locale: string }[] = await knex("videos") + .distinct("locale") + .whereNotNull("published_at") + .whereNotNull("locale") + .orderBy("locale") + + return { + totalVideos: parseInt(countResult.count, 10), + labels: labelRows.map((r) => r.label), + locales: localeRows.map((r) => r.locale), + } + }, +}) + +/** + * Recursively walk blocks to find all `sections.video` components that have + * both a `sectionKey` and a numeric `video` id, building the map needed for + * patchNestedVideoRelations. + * + * Handles dynamic zones (arrays of blocks), containers with nested slots, + * and any other structure that may contain video components. + */ +function collectVideoRelations( + blocks: Record[], + videoMap: Map, +): void { + for (const block of blocks) { + if ( + block.__component === "sections.video" && + typeof block.sectionKey === "string" && + typeof block.video === "number" + ) { + videoMap.set(block.sectionKey, block.video) + } + + // Recurse into container slots + if (Array.isArray(block.slots)) { + for (const slot of block.slots as Record[]) { + if (Array.isArray(slot.content)) { + collectVideoRelations( + slot.content as Record[], + videoMap, + ) + } + } + } + + // Recurse into any array-valued property that looks like nested blocks + for (const value of Object.values(block)) { + if ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === "object" && + value[0] !== null && + "__component" in value[0] + ) { + collectVideoRelations(value as Record[], videoMap) + } + } + } +} diff --git a/apps/seed-studio/next.config.mjs b/apps/seed-studio/next.config.mjs new file mode 100644 index 000000000..3da654a14 --- /dev/null +++ b/apps/seed-studio/next.config.mjs @@ -0,0 +1,21 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { protocol: "https", hostname: "image.mux.com" }, + { protocol: "https", hostname: "imagedelivery.net" }, + { protocol: "https", hostname: "images.unsplash.com" }, + ...(process.env.NEXT_PUBLIC_CMS_HOSTNAME + ? [ + { + protocol: process.env.NEXT_PUBLIC_CMS_PROTOCOL || "https", + hostname: process.env.NEXT_PUBLIC_CMS_HOSTNAME, + pathname: "/uploads/**", + }, + ] + : []), + ], + }, +} + +export default nextConfig diff --git a/apps/seed-studio/package.json b/apps/seed-studio/package.json new file mode 100644 index 000000000..a9d2948e7 --- /dev/null +++ b/apps/seed-studio/package.json @@ -0,0 +1,30 @@ +{ + "name": "@forge/seed-studio", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "next dev --port 3200", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "next": "^16.1.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "eslint": "^9.0.0", + "eslint-config-next": "^16.1.6", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "^5" + } +} diff --git a/apps/seed-studio/postcss.config.mjs b/apps/seed-studio/postcss.config.mjs new file mode 100644 index 000000000..f6c75ff96 --- /dev/null +++ b/apps/seed-studio/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/apps/seed-studio/src/app/actions/publish.ts b/apps/seed-studio/src/app/actions/publish.ts new file mode 100644 index 000000000..67660b1cb --- /dev/null +++ b/apps/seed-studio/src/app/actions/publish.ts @@ -0,0 +1,16 @@ +"use server" + +import type { GeneratedExperience } from "@/lib/ai/experience-schema" +import { publishExperience as publishToStrapi } from "@/lib/strapi-client" + +type PublishResult = { + success: boolean + documentId?: string + error?: string +} + +export async function publishExperience( + experience: GeneratedExperience, +): Promise { + return publishToStrapi(experience) +} diff --git a/apps/seed-studio/src/app/api/auth/route.ts b/apps/seed-studio/src/app/api/auth/route.ts new file mode 100644 index 000000000..443cde550 --- /dev/null +++ b/apps/seed-studio/src/app/api/auth/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +const SESSION_COOKIE = "seed-studio-session" +const SESSION_MAX_AGE = 60 * 60 * 24 // 24 hours + +export async function POST(request: NextRequest) { + const body = (await request.json()) as { password?: string } + const password = body.password ?? "" + const expected = process.env.SEED_STUDIO_PASSWORD ?? "" + + if (!expected || password !== expected) { + return NextResponse.json({ error: "Invalid password" }, { status: 401 }) + } + + const token = crypto.randomUUID() + const response = NextResponse.json({ ok: true }) + + response.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: SESSION_MAX_AGE, + path: "/", + }) + + return response +} diff --git a/apps/seed-studio/src/app/api/chat/route.ts b/apps/seed-studio/src/app/api/chat/route.ts new file mode 100644 index 000000000..a38efaf8d --- /dev/null +++ b/apps/seed-studio/src/app/api/chat/route.ts @@ -0,0 +1,152 @@ +import { spawn } from "node:child_process" +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import type { ChatMessage } from "@/lib/ai/experience-schema" + +function buildPrompt(history: ChatMessage[], userMessage: string): string { + const systemContext = `You are the Seed Studio Assistant — an expert at creating themed Christian experiences for JesusFilm. + +IMPORTANT: When asked to create an experience, you MUST include a JSON code block with the complete experience data in this exact format: + +\`\`\`experience +{ + "title": "Experience Title", + "slug": "experience-slug", + "metaDescription": "Brief description", + "blocks": [ + { + "__component": "sections.video-hero", + "sectionKey": "hero/english", + "streamingUrl": "https://stream.mux.com/example.m3u8", + "heading": "Hero Heading" + }, + { + "__component": "sections.text", + "heading": "Section Heading", + "contentParagraphs": ["Paragraph 1", "Paragraph 2"] + }, + { + "__component": "sections.video", + "sectionKey": "video-1/english", + "video": 0, + "streamingUrl": "https://stream.mux.com/example.m3u8", + "title": "Video Title", + "subtitle": "Video Subtitle" + }, + { + "__component": "sections.related-questions", + "heading": "Questions to Explore", + "questions": [ + { "question": "Q1?", "answer": "A1." }, + { "question": "Q2?", "answer": "A2." } + ] + }, + { + "__component": "sections.bible-quotes-carousel", + "heading": "Scripture", + "sectionKey": "quotes/english", + "quotes": [ + { + "reference": "John 3:16", + "text": "For God so loved the world...", + "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=900", + "backgroundColor": "#1e3a5f" + } + ] + }, + { + "__component": "sections.quiz-button", + "buttonText": "Take the Quiz" + } + ], + "platformOrdering": { + "web": [1, 0, 2, 3, 4, 5], + "mobile": [0, 2, 1, 3, 4, 5] + } +} +\`\`\` + +Section types: sections.video-hero, sections.video, sections.video-carousel, sections.text, sections.container, sections.related-questions, sections.bible-quotes-carousel, sections.quiz-button. + +Platform ordering: mobile leads with video sections, web leads with text/context. + +Always end with suggestion chips: +\`\`\`suggestions +["Suggestion 1", "Suggestion 2", "Suggestion 3"] +\`\`\`` + + const parts = [systemContext, ""] + for (const msg of history) { + const prefix = msg.role === "user" ? "User" : "Assistant" + parts.push(`${prefix}: ${msg.content}`) + } + parts.push(`User: ${userMessage}`) + return parts.join("\n\n") +} + +export async function POST(request: NextRequest) { + const body = (await request.json()) as { + messages: ChatMessage[] + userMessage: string + } + + const prompt = buildPrompt(body.messages, body.userMessage) + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + + const proc = spawn( + "claude", + ["-p", prompt, "--output-format", "text", "-c", "seed-studio"], + { + env: { ...process.env, LANG: "en_US.UTF-8" }, + stdio: ["pipe", "pipe", "pipe"], + }, + ) + + proc.stdout.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8") + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "chunk", text })}\n\n`, + ), + ) + }) + + proc.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8") + // Claude CLI prints progress to stderr — forward as status + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "status", text: text.trim() })}\n\n`, + ), + ) + }) + + proc.on("close", (code) => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: "done", code })}\n\n`), + ) + controller.close() + }) + + proc.on("error", (err) => { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "error", text: err.message })}\n\n`, + ), + ) + controller.close() + }) + }, + }) + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }) +} diff --git a/apps/seed-studio/src/app/globals.css b/apps/seed-studio/src/app/globals.css new file mode 100644 index 000000000..022f88f0c --- /dev/null +++ b/apps/seed-studio/src/app/globals.css @@ -0,0 +1,34 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif; + --font-mono: "Geist Mono", ui-monospace, monospace; + + --color-primary-50: #eef2ff; + --color-primary-100: #e0e7ff; + --color-primary-200: #c7d2fe; + --color-primary-300: #a5b4fc; + --color-primary-400: #818cf8; + --color-primary-500: #6366f1; + --color-primary-600: #4f46e5; + --color-primary-700: #4338ca; + --color-primary-800: #3730a3; + --color-primary-900: #312e81; + + --color-neutral-50: #fafafa; + --color-neutral-100: #f5f5f5; + --color-neutral-200: #e5e5e5; + --color-neutral-300: #d4d4d4; + --color-neutral-400: #a3a3a3; + --color-neutral-500: #737373; + --color-neutral-600: #525252; + --color-neutral-700: #404040; + --color-neutral-800: #262626; + --color-neutral-900: #171717; +} + +body { + font-family: var(--font-sans); + background-color: var(--color-neutral-50); + color: var(--color-neutral-900); +} diff --git a/apps/seed-studio/src/app/layout.tsx b/apps/seed-studio/src/app/layout.tsx new file mode 100644 index 000000000..e398ed430 --- /dev/null +++ b/apps/seed-studio/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next" +import "./globals.css" + +export const metadata: Metadata = { + title: "Seed Studio — AI Experience Creator", + description: + "Create themed experiences for JesusFilm using AI-powered content generation", +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + {children} + + ) +} diff --git a/apps/seed-studio/src/app/login/login-form.tsx b/apps/seed-studio/src/app/login/login-form.tsx new file mode 100644 index 000000000..c6daa5f13 --- /dev/null +++ b/apps/seed-studio/src/app/login/login-form.tsx @@ -0,0 +1,72 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { LogIn } from "lucide-react" +import { cn } from "@/lib/cn" + +export function LoginForm() { + const router = useRouter() + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [isPending, setIsPending] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError("") + setIsPending(true) + + try { + const res = await fetch("/api/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }) + + if (!res.ok) { + setError("Invalid password") + return + } + + router.push("/") + router.refresh() + } catch { + setError("Something went wrong") + } finally { + setIsPending(false) + } + } + + return ( +
+
+ setPassword(e.target.value)} + placeholder="Password" + className={cn( + "w-full rounded-xl border border-neutral-200 px-4 py-2.5", + "text-sm outline-none transition", + "focus:border-primary-300 focus:ring-2 focus:ring-primary-100", + )} + autoFocus + /> + {error ?

{error}

: null} +
+ +
+ ) +} diff --git a/apps/seed-studio/src/app/login/page.tsx b/apps/seed-studio/src/app/login/page.tsx new file mode 100644 index 000000000..635e95216 --- /dev/null +++ b/apps/seed-studio/src/app/login/page.tsx @@ -0,0 +1,19 @@ +import { LoginForm } from "./login-form" + +export default function LoginPage() { + return ( +
+
+
+

+ Seed Studio +

+

+ Enter the password to access the experience creator +

+
+ +
+
+ ) +} diff --git a/apps/seed-studio/src/app/page.tsx b/apps/seed-studio/src/app/page.tsx new file mode 100644 index 000000000..fcdfdfa99 --- /dev/null +++ b/apps/seed-studio/src/app/page.tsx @@ -0,0 +1,5 @@ +import { Studio } from "@/components/studio" + +export default function HomePage() { + return +} diff --git a/apps/seed-studio/src/components/chat/ChatInput.tsx b/apps/seed-studio/src/components/chat/ChatInput.tsx new file mode 100644 index 000000000..0ae4e168f --- /dev/null +++ b/apps/seed-studio/src/components/chat/ChatInput.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useCallback, useRef, useState } from "react" +import { SendHorizontal } from "lucide-react" + +import { cn } from "@/lib/cn" + +type ChatInputProps = { + onSend: (message: string) => void + disabled: boolean +} + +export function ChatInput({ onSend, disabled }: ChatInputProps) { + const [value, setValue] = useState("") + const textareaRef = useRef(null) + + const adjustHeight = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + textarea.style.height = "auto" + textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px` + }, []) + + const handleSubmit = useCallback(() => { + const trimmed = value.trim() + if (!trimmed || disabled) return + onSend(trimmed) + setValue("") + if (textareaRef.current) { + textareaRef.current.style.height = "auto" + } + }, [value, disabled, onSend]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + }, + [handleSubmit], + ) + + return ( +
+