diff --git a/.github/workflows/release-compositor.yml b/.github/workflows/release-compositor.yml new file mode 100644 index 0000000..98b7d27 --- /dev/null +++ b/.github/workflows/release-compositor.yml @@ -0,0 +1,93 @@ +name: Release Compositor + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-linux + platform: webreel-linux-x86_64 + - os: ubuntu-latest + target: aarch64-linux + platform: webreel-linux-aarch64 + - os: macos-latest + target: x86_64-macos + platform: webreel-darwin-x86_64 + - os: macos-latest + target: aarch64-macos + platform: webreel-darwin-aarch64 + - os: windows-latest + target: x86_64-windows + platform: webreel-windows-x86_64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2 + with: + version: 0.15.2 + + - name: Fetch deps + working-directory: packages/@webreel/compositor + run: bash scripts/fetch-deps.sh + + - name: Build + working-directory: packages/@webreel/compositor + run: | + zig build \ + -Doptimize=ReleaseFast \ + -Dtarget=${{ matrix.target }} \ + -Dexe-name=webreel + + - name: Package archive + working-directory: packages/@webreel/compositor + shell: bash + run: | + mkdir -p dist + if [ -f zig-out/bin/webreel.exe ]; then + cp zig-out/bin/webreel.exe dist/webreel.exe + tar czf dist/${{ matrix.platform }}.tar.gz -C dist webreel.exe + rm dist/webreel.exe + else + cp zig-out/bin/webreel dist/webreel + tar czf dist/${{ matrix.platform }}.tar.gz -C dist webreel + rm dist/webreel + fi + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ matrix.platform }} + path: packages/@webreel/compositor/dist/${{ matrix.platform }}.tar.gz + + release: + needs: build + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: artifacts + merge-multiple: true + + - name: Generate checksums + run: | + cd artifacts + sha256sum *.tar.gz > checksums.txt + + - name: Create release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + with: + files: artifacts/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index ffeb0f9..012a1c1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist !.env.example coverage *.tsbuildinfo +next-env.d.ts *.log package-lock.json demo-reel.mp4 diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/studio/components.json b/apps/studio/components.json new file mode 100644 index 0000000..2a42785 --- /dev/null +++ b/apps/studio/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts new file mode 100644 index 0000000..9493cd6 --- /dev/null +++ b/apps/studio/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + devIndicators: false, +}; + +export default config; diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 0000000..6e6f650 --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,44 @@ +{ + "name": "@webreel/studio", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "portless run --name studio.webreel next dev", + "build": "next build", + "start": "next start", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/autocomplete": "^6", + "@codemirror/commands": "^6", + "@codemirror/lang-json": "^6", + "@codemirror/language": "^6", + "@codemirror/state": "^6", + "@codemirror/theme-one-dark": "^6", + "@codemirror/view": "^6", + "class-variance-authority": "^0.7", + "clsx": "^2", + "cmdk": "^1.1.1", + "codemirror": "^6", + "geist": "^1", + "jotai": "^2", + "lucide-react": "^0.575.0", + "next": "~16.1.6", + "next-themes": "^0.4", + "radix-ui": "^1", + "react": "~19.2.4", + "react-dom": "~19.2.4", + "react-resizable-panels": "^4", + "tailwind-merge": "^3", + "tw-animate-css": "^1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/studio/postcss.config.mjs b/apps/studio/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/studio/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/studio/src/app/api/cors/route.ts b/apps/studio/src/app/api/cors/route.ts new file mode 100644 index 0000000..9d52cb9 --- /dev/null +++ b/apps/studio/src/app/api/cors/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isBlockedUrl } from "@/lib/url-validation"; + +const STRIPPED_REQUEST_HEADERS = new Set([ + "host", + "origin", + "referer", + "cookie", + "authorization", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", +]); + +const STRIPPED_RESPONSE_HEADERS = new Set([ + "content-encoding", + "content-security-policy", + "content-security-policy-report-only", + "x-frame-options", + "strict-transport-security", + "set-cookie", + "transfer-encoding", +]); + +function resolveUrl(raw: string, base?: string): string { + if (/^https?:\/\//i.test(raw)) return raw; + if (!base) return raw; + try { + return new URL(raw, base).href; + } catch { + return raw; + } +} + +function rewriteCssUrls(css: string, baseUrl: string): string { + return css.replace(/url\(\s*(['"]?)(\/?[^)'"]+)\1\s*\)/g, (_match, quote, rawUrl) => { + if (rawUrl.startsWith("data:") || rawUrl.startsWith("#")) return _match; + const absolute = resolveUrl(rawUrl, baseUrl); + if (!/^https?:\/\//i.test(absolute)) return _match; + return `url(${quote}/api/cors?url=${encodeURIComponent(absolute)}${quote})`; + }); +} + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get("url"); + if (!url) { + return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); + } + + if (isBlockedUrl(url)) { + return NextResponse.json({ error: "URL not allowed" }, { status: 403 }); + } + + try { + const headers: Record = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "*/*", + }; + + for (const [key, value] of request.headers.entries()) { + if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) { + headers[key] = value; + } + } + + const response = await fetch(url, { + headers, + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + + const contentType = response.headers.get("content-type") || ""; + const responseHeaders = new Headers(); + + for (const [key, value] of response.headers.entries()) { + if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value); + } + } + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Methods", "GET, OPTIONS"); + responseHeaders.set("Access-Control-Allow-Headers", "*"); + + if (contentType.includes("text/css")) { + const css = await response.text(); + const baseUrl = url.replace(/[?#].*$/, "").replace(/\/[^/]*$/, "/"); + const rewritten = rewriteCssUrls(css, baseUrl); + responseHeaders.set("Content-Type", contentType); + return new NextResponse(rewritten, { + status: response.status, + headers: responseHeaders, + }); + } + + responseHeaders.delete("content-encoding"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/apps/studio/src/app/api/p/[...path]/route.ts b/apps/studio/src/app/api/p/[...path]/route.ts new file mode 100644 index 0000000..2ba6fd7 --- /dev/null +++ b/apps/studio/src/app/api/p/[...path]/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PREVIEW_SCRIPT } from "@/lib/preview-script"; +import { + rewriteHtml, + rewriteCssUrls, + injectScript, + STRIPPED_RESPONSE_HEADERS, + DEFAULT_USER_AGENT, +} from "@/lib/proxy-utils"; +import { isBlockedUrl } from "@/lib/url-validation"; + +function reconstructUrl(pathSegments: string[]): string | null { + if (pathSegments.length < 2) return null; + const protocol = pathSegments[0]; + if (protocol !== "https" && protocol !== "http") return null; + const rest = pathSegments.slice(1).join("/"); + return `${protocol}://${rest}`; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path: pathSegments } = await params; + const targetUrl = reconstructUrl(pathSegments); + if (!targetUrl) { + return NextResponse.json({ error: "Invalid proxy path" }, { status: 400 }); + } + + const searchParams = request.nextUrl.search; + const fullUrl = searchParams ? `${targetUrl}${searchParams}` : targetUrl; + + if (isBlockedUrl(fullUrl)) { + return NextResponse.json({ error: "URL not allowed" }, { status: 403 }); + } + + try { + const response = await fetch(fullUrl, { + headers: { + "User-Agent": DEFAULT_USER_AGENT, + Accept: request.headers.get("accept") ?? "*/*", + "Accept-Language": request.headers.get("accept-language") ?? "en-US,en;q=0.9", + }, + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + + const contentType = response.headers.get("content-type") || ""; + const responseHeaders = new Headers(); + + for (const [key, value] of response.headers.entries()) { + if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value); + } + } + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.delete("content-encoding"); + + if (contentType.includes("text/html") || contentType.includes("application/xhtml")) { + let html = await response.text(); + const finalUrl = response.url || fullUrl; + html = rewriteHtml(html, finalUrl); + html = injectScript(html, PREVIEW_SCRIPT); + responseHeaders.set("Content-Type", "text/html; charset=utf-8"); + return new NextResponse(html, { + status: response.status, + headers: responseHeaders, + }); + } + + if (contentType.includes("text/css")) { + const css = await response.text(); + const url = new URL(fullUrl); + const originPrefix = `/api/p/${url.protocol.replace(":", "")}/${url.host}`; + const rewritten = rewriteCssUrls(css, originPrefix); + responseHeaders.set("Content-Type", contentType); + return new NextResponse(rewritten, { + status: response.status, + headers: responseHeaders, + }); + } + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/apps/studio/src/app/api/proxy/route.ts b/apps/studio/src/app/api/proxy/route.ts new file mode 100644 index 0000000..cc6a4ad --- /dev/null +++ b/apps/studio/src/app/api/proxy/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PREVIEW_SCRIPT } from "@/lib/preview-script"; +import { rewriteHtml, injectScript, DEFAULT_USER_AGENT } from "@/lib/proxy-utils"; +import { isBlockedUrl } from "@/lib/url-validation"; + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get("url"); + if (!url) { + return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); + } + + if (isBlockedUrl(url)) { + return NextResponse.json({ error: "URL not allowed" }, { status: 403 }); + } + + try { + const response = await fetch(url, { + headers: { + "User-Agent": DEFAULT_USER_AGENT, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + + const contentType = response.headers.get("content-type") || ""; + if ( + !contentType.includes("text/html") && + !contentType.includes("application/xhtml") + ) { + return new NextResponse(response.body, { + status: response.status, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + }, + }); + } + + let html = await response.text(); + const finalUrl = response.url || url; + html = rewriteHtml(html, finalUrl); + html = injectScript(html, PREVIEW_SCRIPT); + + return new NextResponse(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/apps/studio/src/app/api/render/route.ts b/apps/studio/src/app/api/render/route.ts new file mode 100644 index 0000000..c924e34 --- /dev/null +++ b/apps/studio/src/app/api/render/route.ts @@ -0,0 +1,95 @@ +import { NextRequest } from "next/server"; +import { spawn } from "node:child_process"; +import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +const VIDEO_NAME_RE = /^[a-zA-Z0-9._-]+$/; + +export async function POST(request: NextRequest) { + const body = (await request.json()) as { + config: unknown; + video?: string; + videos?: string[]; + }; + + if (!body.config) { + return Response.json({ error: "Missing config" }, { status: 400 }); + } + + const allVideos = body.videos ?? (body.video ? [body.video] : []); + for (const v of allVideos) { + if (!VIDEO_NAME_RE.test(v)) { + return Response.json({ error: "Invalid video name" }, { status: 400 }); + } + } + + const tempDir = join(tmpdir(), "webreel-studio"); + mkdirSync(tempDir, { recursive: true }); + const configPath = join(tempDir, `${randomUUID()}.json`); + writeFileSync(configPath, JSON.stringify(body.config, null, 2)); + + const webreelBin = + process.env.WEBREEL_BIN ?? + join(process.cwd(), "..", "..", "packages", "webreel", "dist", "index.js"); + + const args = ["record"]; + if (allVideos.length > 0) { + args.push(...allVideos); + } + args.push("-c", configPath, "--verbose"); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const child = spawn("node", [webreelBin, ...args], { + cwd: tempDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + function send(type: string, data: string) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type, data })}\n\n`)); + } + + child.stdout.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n").filter(Boolean); + for (const line of lines) { + send("stdout", line); + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n").filter(Boolean); + for (const line of lines) { + send("stderr", line); + } + }); + + child.on("close", (code) => { + send("exit", String(code ?? 0)); + try { + unlinkSync(configPath); + } catch {} + controller.close(); + }); + + child.on("error", (err) => { + send("error", err.message); + try { + unlinkSync(configPath); + } catch {} + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/apps/studio/src/app/api/video/route.ts b/apps/studio/src/app/api/video/route.ts new file mode 100644 index 0000000..aff0853 --- /dev/null +++ b/apps/studio/src/app/api/video/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from "next/server"; +import { readFile, stat } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +const MIME_TYPES: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".gif": "image/gif", +}; + +const ALLOWED_ROOTS = [ + resolve(tmpdir(), "webreel-studio"), + resolve(process.cwd(), "output"), +]; + +export async function GET(request: NextRequest) { + const path = request.nextUrl.searchParams.get("path"); + if (!path) { + return Response.json({ error: "Missing path" }, { status: 400 }); + } + + const resolved = resolve(path); + const allowed = ALLOWED_ROOTS.some((root) => resolved.startsWith(root + "/")); + if (!allowed) { + return Response.json({ error: "Forbidden path" }, { status: 403 }); + } + + try { + const stats = await stat(resolved); + if (!stats.isFile()) { + return Response.json({ error: "Not a file" }, { status: 404 }); + } + + const data = await readFile(resolved); + const ext = extname(resolved).toLowerCase(); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + + return new Response(data, { + headers: { + "Content-Type": contentType, + "Content-Length": String(data.length), + "Content-Disposition": `inline; filename="${resolved.split("/").pop()}"`, + "Cache-Control": "no-cache", + }, + }); + } catch { + return Response.json({ error: "File not found" }, { status: 404 }); + } +} diff --git a/apps/studio/src/app/favicon.ico b/apps/studio/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/apps/studio/src/app/favicon.ico differ diff --git a/apps/studio/src/app/globals.css b/apps/studio/src/app/globals.css new file mode 100644 index 0000000..4813538 --- /dev/null +++ b/apps/studio/src/app/globals.css @@ -0,0 +1,145 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --success: #22c55e; + --warning: #eab308; +} + +.dark { + --background: #0a0a0a; + --foreground: #e5e5e5; + --card: #141414; + --card-foreground: #e5e5e5; + --popover: #141414; + --popover-foreground: #e5e5e5; + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: #1a1a1a; + --secondary-foreground: #e5e5e5; + --muted: #1a1a1a; + --muted-foreground: #737373; + --accent: #1a1a1a; + --accent-foreground: #e5e5e5; + --destructive: #ef4444; + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --sidebar: #141414; + --sidebar-foreground: #e5e5e5; + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: #1a1a1a; + --sidebar-accent-foreground: #e5e5e5; + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --success: #22c55e; + --warning: #eab308; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + margin: 0; + overflow: hidden; +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --color-success: var(--success); + --color-warning: var(--warning); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +button { + cursor: pointer; +} diff --git a/apps/studio/src/app/layout.tsx b/apps/studio/src/app/layout.tsx new file mode 100644 index 0000000..da67077 --- /dev/null +++ b/apps/studio/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Geist } from "next/font/google"; +import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/theme-provider"; +import { JotaiProvider } from "@/store/provider"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata: Metadata = { + title: "Webreel Studio", + description: "Visual editor for webreel configurations", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/studio/src/app/page.tsx b/apps/studio/src/app/page.tsx new file mode 100644 index 0000000..3d42125 --- /dev/null +++ b/apps/studio/src/app/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useAtomValue } from "jotai/react"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useFileOperations } from "@/hooks/use-file-operations"; +import { LeftPane } from "@/components/left-pane"; +import { Preview } from "@/components/preview"; +import { PropsPane } from "@/components/props-pane"; +import { Header } from "@/components/header"; +import { CommandPalette } from "@/components/command-palette"; +import { GlobalSettings } from "@/components/global-settings"; +import { Separator } from "@/components/ui/separator"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { renderStatusAtom, parsedConfigAtom, selectedVideoAtom } from "@/store/config"; + +export default function StudioPage() { + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const renderStatus = useAtomValue(renderStatusAtom); + const { config } = useAtomValue(parsedConfigAtom); + const selectedVideo = useAtomValue(selectedVideoAtom); + + const { handleSave, handleOpen, handleNew } = useFileOperations(); + + const handleStartRender = useCallback(async () => { + if (renderStatus === "running" || !config || !selectedVideo) return; + window.dispatchEvent(new CustomEvent("webreel:start-render")); + }, [renderStatus, config, selectedVideo]); + + useKeyboardShortcuts({ + onSave: handleSave, + onOpen: handleOpen, + onCommandPalette: () => setCmdPaletteOpen(true), + onStartRender: handleStartRender, + }); + + if (isDesktop) { + return ( +
+
setSettingsOpen(true)} + onOpenCommandPalette={() => setCmdPaletteOpen(true)} + /> + + + + + + + + + + + + + + setSettingsOpen(true)} + /> + +
+ ); + } + + return ( +
+
setSettingsOpen(true)} + onOpenCommandPalette={() => setCmdPaletteOpen(true)} + /> + +
+ + Timeline + Preview + Properties + +
+ + + + + + + + + + +
+ setSettingsOpen(true)} + /> + +
+ ); +} diff --git a/apps/studio/src/components/command-palette.tsx b/apps/studio/src/components/command-palette.tsx new file mode 100644 index 0000000..cf75c57 --- /dev/null +++ b/apps/studio/src/components/command-palette.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { Command } from "cmdk"; +import { + configJsonAtom, + parsedConfigAtom, + selectedVideoAtom, + selectedStepIndexAtom, + videoNamesAtom, + commitConfigAtom, + undoAtom, + redoAtom, + canUndoAtom, + canRedoAtom, + STEP_TEMPLATES, + selectedVideoConfigAtom, +} from "@/store/config"; +import { Plus, Undo2, Redo2, Play, Video, Trash2, Copy, Sun, Moon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { cn } from "@/lib/utils"; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave?: () => void; + onOpen?: () => void; + onNew?: () => void; + onStartRender?: () => void; + onOpenSettings?: () => void; +} + +export function CommandPalette({ + open, + onOpenChange, + onSave, + onOpen, + onNew, + onStartRender, + onOpenSettings, +}: CommandPaletteProps) { + const [search, setSearch] = useState(""); + const videoNames = useAtomValue(videoNamesAtom); + const [selectedVideo, setSelectedVideo] = useAtom(selectedVideoAtom); + const videoConfig = useAtomValue(selectedVideoConfigAtom); + const [selectedStep, setSelectedStep] = useAtom(selectedStepIndexAtom); + const [configJson] = useAtom(configJsonAtom); + const { config } = useAtomValue(parsedConfigAtom); + const commit = useSetAtom(commitConfigAtom); + const undo = useSetAtom(undoAtom); + const redo = useSetAtom(redoAtom); + const canUndo = useAtomValue(canUndoAtom); + const canRedo = useAtomValue(canRedoAtom); + const { setTheme, resolvedTheme } = useTheme(); + + useEffect(() => { + if (open) setSearch(""); + }, [open]); + + const close = useCallback(() => onOpenChange(false), [onOpenChange]); + + const addStepFromTemplate = useCallback( + (templateIndex: number) => { + if (!config || !selectedVideo) return; + const template = STEP_TEMPLATES[templateIndex]; + if (!template) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const steps = video.steps as Record[]; + steps.push(JSON.parse(JSON.stringify(template.step))); + commit(JSON.stringify(draft, null, 2)); + setSelectedStep(steps.length - 1); + }, + [config, configJson, selectedVideo, commit, setSelectedStep], + ); + + const duplicateStep = useCallback(() => { + if (!config || !selectedVideo || !videoConfig || selectedStep < 0) return; + const step = videoConfig.steps[selectedStep]; + if (!step) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const steps = video.steps as Record[]; + steps.splice(selectedStep + 1, 0, JSON.parse(JSON.stringify(step))); + commit(JSON.stringify(draft, null, 2)); + setSelectedStep(selectedStep + 1); + }, [ + config, + configJson, + selectedVideo, + videoConfig, + selectedStep, + commit, + setSelectedStep, + ]); + + const deleteStep = useCallback(() => { + if (!config || !selectedVideo || selectedStep < 0) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const steps = video.steps as Record[]; + steps.splice(selectedStep, 1); + commit(JSON.stringify(draft, null, 2)); + setSelectedStep(Math.min(selectedStep, steps.length - 1)); + }, [config, configJson, selectedVideo, selectedStep, commit, setSelectedStep]); + + if (!open) return null; + + return ( +
+
+
+ + + + + No results found. + + + + { + close(); + onNew?.(); + }} + > + New Project + + { + close(); + onOpen?.(); + }} + > + Open File... + + { + close(); + onSave?.(); + }} + > + Save + + + + + { + undo(); + close(); + }} + > + Undo + + { + redo(); + close(); + }} + > + Redo + + {videoConfig && selectedStep >= 0 && ( + <> + { + duplicateStep(); + close(); + }} + > + Duplicate Step + + { + deleteStep(); + close(); + }} + > + Delete Step + + + )} + + + + {videoNames.map((name) => ( + { + setSelectedVideo(name); + setSelectedStep(-1); + close(); + }} + > + + ))} + + + {selectedVideo && ( + + {STEP_TEMPLATES.map((template, i) => ( + { + addStepFromTemplate(i); + close(); + }} + > + + {template.label} + + {template.description} + + + ))} + + )} + + + { + close(); + onStartRender?.(); + }} + > + Start Recording + + { + close(); + onOpenSettings?.(); + }} + > + Settings + + { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + close(); + }} + > + {resolvedTheme === "dark" ? ( + + ) : ( + + )} + Toggle Theme + + + + +
+
+ ); +} + +function CommandItem({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} diff --git a/apps/studio/src/components/diff-view.tsx b/apps/studio/src/components/diff-view.tsx new file mode 100644 index 0000000..ddd2836 --- /dev/null +++ b/apps/studio/src/components/diff-view.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useMemo } from "react"; +import { useAtomValue } from "jotai/react"; +import { configJsonAtom, savedConfigJsonAtom, isDirtyAtom } from "@/store/config"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; + +function computeDiff( + oldLines: string[], + newLines: string[], +): Array<{ type: "same" | "add" | "remove"; line: string; lineNumber: number }> { + const result: Array<{ + type: "same" | "add" | "remove"; + line: string; + lineNumber: number; + }> = []; + const maxLen = Math.max(oldLines.length, newLines.length); + let oi = 0; + let ni = 0; + let lineNum = 1; + + while (oi < oldLines.length || ni < newLines.length) { + if (oi < oldLines.length && ni < newLines.length && oldLines[oi] === newLines[ni]) { + result.push({ type: "same", line: newLines[ni], lineNumber: lineNum }); + oi++; + ni++; + } else if ( + oi < oldLines.length && + (ni >= newLines.length || oldLines[oi] !== newLines[ni]) + ) { + result.push({ type: "remove", line: oldLines[oi], lineNumber: lineNum }); + oi++; + } else { + result.push({ type: "add", line: newLines[ni], lineNumber: lineNum }); + ni++; + } + lineNum++; + if (lineNum > maxLen + 100) break; + } + + return result; +} + +export function DiffView() { + const configJson = useAtomValue(configJsonAtom); + const savedConfigJson = useAtomValue(savedConfigJsonAtom); + const isDirty = useAtomValue(isDirtyAtom); + + const diff = useMemo(() => { + if (!isDirty) return []; + const oldLines = savedConfigJson.split("\n"); + const newLines = configJson.split("\n"); + return computeDiff(oldLines, newLines); + }, [configJson, savedConfigJson, isDirty]); + + if (!isDirty) { + return ( +
+

No unsaved changes.

+
+ ); + } + + return ( + +
+
+ {diff.map((entry, i) => ( +
+ + {entry.type === "add" ? "+" : entry.type === "remove" ? "-" : " "} + + {entry.line} +
+ ))} +
+
+
+ ); +} diff --git a/apps/studio/src/components/env-vars.tsx b/apps/studio/src/components/env-vars.tsx new file mode 100644 index 0000000..494d99d --- /dev/null +++ b/apps/studio/src/components/env-vars.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; +import { useAtom, useAtomValue } from "jotai/react"; +import { configJsonAtom, envVarsAtom } from "@/store/config"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AlertCircle } from "lucide-react"; + +function detectEnvVars(json: string): string[] { + const matches = json.match(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g); + if (!matches) return []; + const names = new Set(); + for (const m of matches) { + const name = m.replace(/^\$\{?/, "").replace(/\}$/, ""); + names.add(name); + } + return Array.from(names).sort(); +} + +export function EnvVarsPanel() { + const configJson = useAtomValue(configJsonAtom); + const [envVars, setEnvVars] = useAtom(envVarsAtom); + + const detectedVars = useMemo(() => detectEnvVars(configJson), [configJson]); + + if (detectedVars.length === 0) { + return ( +
+

+ No environment variables detected in config. +

+
+ ); + } + + return ( + +
+

+ Variables referenced as ${"{VAR}"} in the config. Set values here for preview + and recording. +

+ {detectedVars.map((name) => { + const hasValue = name in envVars && envVars[name] !== ""; + return ( +
+
+ + {!hasValue && } +
+ + setEnvVars((prev) => ({ ...prev, [name]: e.target.value })) + } + /> +
+ ); + })} +
+
+ ); +} diff --git a/apps/studio/src/components/global-settings.tsx b/apps/studio/src/components/global-settings.tsx new file mode 100644 index 0000000..10388b7 --- /dev/null +++ b/apps/studio/src/components/global-settings.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useCallback } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + parsedConfigAtom, + commitConfigAtom, + watchModeAtom, +} from "@/store/config"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +export function GlobalSettings({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [configJson] = useAtom(configJsonAtom); + const { config } = useAtomValue(parsedConfigAtom); + const commit = useSetAtom(commitConfigAtom); + const [watchMode, setWatchMode] = useAtom(watchModeAtom); + + const updateRootField = useCallback( + (field: string, value: unknown) => { + if (!config) return; + const draft = JSON.parse(configJson) as Record; + if (value === "" || value === undefined) { + delete draft[field]; + } else { + draft[field] = value; + } + commit(JSON.stringify(draft, null, 2)); + }, + [config, configJson, commit], + ); + + const updateNumericField = useCallback( + (field: string, raw: string) => { + const v = parseInt(raw, 10); + updateRootField(field, isNaN(v) ? undefined : v); + }, + [updateRootField], + ); + + return ( + + + + Global Settings + + +
+
+

Schema

+ + + +
+ +
+ +
+

Output

+ + updateRootField("outDir", e.target.value)} + /> + + + updateRootField("baseUrl", e.target.value)} + /> + +
+ +
+ +
+

+ Timing Defaults +

+ + updateNumericField("defaultDelay", e.target.value)} + /> + + + updateNumericField("clickDwell", e.target.value)} + /> + +
+ +
+ +
+

Studio

+
+ + +
+

+ Auto-re-record when config changes are saved +

+
+
+ + +
+ ); +} diff --git a/apps/studio/src/components/header.tsx b/apps/studio/src/components/header.tsx new file mode 100644 index 0000000..dd6d39e --- /dev/null +++ b/apps/studio/src/components/header.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + savedConfigJsonAtom, + fileHandleAtom, + fileNameAtom, + isDirtyAtom, + canUndoAtom, + canRedoAtom, + undoAtom, + redoAtom, + commitConfigAtom, +} from "@/store/config"; +import { useFileOperations } from "@/hooks/use-file-operations"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ChevronDown, + FileUp, + FolderOpen, + Save, + FilePlus, + Download, + Undo2, + Redo2, + Settings, + Circle, +} from "lucide-react"; + +export interface HeaderHandlers { + onOpenSettings?: () => void; + onOpenCommandPalette?: () => void; +} + +export function Header({ onOpenSettings }: HeaderHandlers) { + const fileName = useAtomValue(fileNameAtom); + const isDirty = useAtomValue(isDirtyAtom); + const canUndo = useAtomValue(canUndoAtom); + const canRedo = useAtomValue(canRedoAtom); + const undo = useSetAtom(undoAtom); + const redo = useSetAtom(redoAtom); + const commit = useSetAtom(commitConfigAtom); + const setSavedConfigJson = useSetAtom(savedConfigJsonAtom); + const setFileHandle = useSetAtom(fileHandleAtom); + const setFileName = useSetAtom(fileNameAtom); + const fileInputRef = useRef(null); + + const { + handleSave, + handleSaveAs, + handleOpen: handleOpenFile, + handleNew, + downloadConfig, + hasFileSystemAccess, + } = useFileOperations(); + + const handleOpen = useCallback(async () => { + if (hasFileSystemAccess) { + await handleOpenFile(); + } else { + fileInputRef.current?.click(); + } + }, [hasFileSystemAccess, handleOpenFile]); + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + commit(text); + setSavedConfigJson(text); + setFileHandle(null); + setFileName(file.name); + }; + reader.readAsText(file); + e.target.value = ""; + }, + [commit, setSavedConfigJson, setFileHandle, setFileName], + ); + + return ( +
+ + + + + + + + New Project + + + + Open... + Ctrl+O + + + + + Save + Ctrl+S + + + + Save As... + + + + + Export JSON + + + + + {fileName && ( +
+ {fileName} + {isDirty && } +
+ )} + {!fileName && isDirty && ( + unsaved + )} + +
+ + +
+ + +
+ + +
+ ); +} diff --git a/apps/studio/src/components/include-manager.tsx b/apps/studio/src/components/include-manager.tsx new file mode 100644 index 0000000..18d25d4 --- /dev/null +++ b/apps/studio/src/components/include-manager.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + parsedConfigAtom, + selectedVideoAtom, + selectedVideoConfigAtom, + commitConfigAtom, +} from "@/store/config"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Plus, Trash2, FileText } from "lucide-react"; + +function IncludeList({ + title, + includes, + onAdd, + onRemove, + onChange, +}: { + title: string; + includes: string[]; + onAdd: () => void; + onRemove: (index: number) => void; + onChange: (index: number, value: string) => void; +}) { + return ( +
+
+

{title}

+ +
+ {includes.length === 0 ? ( +

+ No includes. Steps from included files are prepended to the video. +

+ ) : ( +
+ {includes.map((path, i) => ( +
+ + onChange(i, e.target.value)} + /> + +
+ ))} +
+ )} +
+ ); +} + +export function IncludeManager() { + const selectedVideo = useAtomValue(selectedVideoAtom); + const videoConfig = useAtomValue(selectedVideoConfigAtom); + const [configJson] = useAtom(configJsonAtom); + const { config } = useAtomValue(parsedConfigAtom); + const commit = useSetAtom(commitConfigAtom); + + const rootIncludes = config?.include ?? []; + const videoIncludes = videoConfig?.include ?? []; + + const updateRootIncludes = useCallback( + (updater: (arr: string[]) => string[]) => { + if (!config) return; + const draft = JSON.parse(configJson) as Record; + const current = (draft.include as string[]) ?? []; + const next = updater([...current]); + if (next.length > 0) { + draft.include = next; + } else { + delete draft.include; + } + commit(JSON.stringify(draft, null, 2)); + }, + [config, configJson, commit], + ); + + const updateVideoIncludes = useCallback( + (updater: (arr: string[]) => string[]) => { + if (!config || !selectedVideo) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const current = (video.include as string[]) ?? []; + const next = updater([...current]); + if (next.length > 0) { + video.include = next; + } else { + delete video.include; + } + commit(JSON.stringify(draft, null, 2)); + }, + [config, configJson, selectedVideo, commit], + ); + + return ( + +
+ updateRootIncludes((arr) => [...arr, ""])} + onRemove={(i) => + updateRootIncludes((arr) => { + arr.splice(i, 1); + return arr; + }) + } + onChange={(i, v) => + updateRootIncludes((arr) => { + arr[i] = v; + return arr; + }) + } + /> + +
+ + {selectedVideo ? ( + updateVideoIncludes((arr) => [...arr, ""])} + onRemove={(i) => + updateVideoIncludes((arr) => { + arr.splice(i, 1); + return arr; + }) + } + onChange={(i, v) => + updateVideoIncludes((arr) => { + arr[i] = v; + return arr; + }) + } + /> + ) : ( +

+ Select a video to manage its includes. +

+ )} + +
+ +

+ Include files export a steps array that gets prepended to the video's steps + at recording time. Paths are relative to the config file directory. Supports + .json, .ts, and .js files. +

+
+ + ); +} diff --git a/apps/studio/src/components/json-editor.tsx b/apps/studio/src/components/json-editor.tsx new file mode 100644 index 0000000..72ada35 --- /dev/null +++ b/apps/studio/src/components/json-editor.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { useAtom, useAtomValue } from "jotai/react"; +import { configJsonAtom, parsedConfigAtom } from "@/store/config"; +import { EditorState } from "@codemirror/state"; +import { EditorView, keymap, lineNumbers } from "@codemirror/view"; +import { json } from "@codemirror/lang-json"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { + bracketMatching, + foldGutter, + indentOnInput, + syntaxHighlighting, + defaultHighlightStyle, +} from "@codemirror/language"; +import { + closeBrackets, + closeBracketsKeymap, + autocompletion, +} from "@codemirror/autocomplete"; + +const editorTheme = EditorView.theme({ + "&": { + height: "100%", + fontSize: "12px", + }, + ".cm-scroller": { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", + overflow: "auto", + }, + ".cm-gutters": { + backgroundColor: "transparent", + borderRight: "1px solid oklch(1 0 0 / 10%)", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, +}); + +export function JsonEditor() { + const [configJson, setConfigJson] = useAtom(configJsonAtom); + const { error } = useAtomValue(parsedConfigAtom); + const editorRef = useRef(null); + const viewRef = useRef(null); + const isInternalUpdate = useRef(false); + + const onUpdate = useCallback( + (value: string) => { + isInternalUpdate.current = true; + setConfigJson(value); + }, + [setConfigJson], + ); + + useEffect(() => { + if (!editorRef.current) return; + + const state = EditorState.create({ + doc: configJson, + extensions: [ + lineNumbers(), + history(), + foldGutter(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + json(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + oneDark, + editorTheme, + keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onUpdate(update.state.doc.toString()); + } + }), + EditorView.lineWrapping, + ], + }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + }, []); + + useEffect(() => { + if (isInternalUpdate.current) { + isInternalUpdate.current = false; + return; + } + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (current !== configJson) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: configJson }, + }); + } + }, [configJson]); + + return ( +
+ {error && ( +
+ Parse error +
+ )} +
+
+ ); +} diff --git a/apps/studio/src/components/left-pane.tsx b/apps/studio/src/components/left-pane.tsx new file mode 100644 index 0000000..98681b7 --- /dev/null +++ b/apps/studio/src/components/left-pane.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Timeline } from "@/components/timeline"; +import { JsonEditor } from "@/components/json-editor"; +import { EnvVarsPanel } from "@/components/env-vars"; +import { DiffView } from "@/components/diff-view"; +import { ValidationPanel, ValidationBadge } from "@/components/validation-panel"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +export function LeftPane() { + return ( + +
+ + + Timeline + + + JSON + + + Diff + + + Env + + + Issues + + + +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/studio/src/components/preview-chrome.tsx b/apps/studio/src/components/preview-chrome.tsx new file mode 100644 index 0000000..29e8569 --- /dev/null +++ b/apps/studio/src/components/preview-chrome.tsx @@ -0,0 +1,172 @@ +"use client"; + +import type { WindowConfig, BackgroundConfig } from "@/store/config"; +import { buildBackgroundStyle, buildShadowStyle } from "@/lib/preview-utils"; +import { cn } from "@/lib/utils"; + +export function PreviewWithChrome({ + canvasW, + canvasH, + vpWidth, + vpHeight, + scale, + titlebarH, + titlebarVisible, + borderRadius, + windowCfg, + backgroundCfg, + iframeKey, + iframeRef, + iframeSrc, + selectedVideo, + pickMode, + previewCursor, +}: { + canvasW: number; + canvasH: number; + vpWidth: number; + vpHeight: number; + scale: number; + titlebarH: number; + titlebarVisible: boolean; + borderRadius: number; + windowCfg?: WindowConfig; + backgroundCfg?: BackgroundConfig; + iframeKey: number; + iframeRef: React.RefObject; + iframeSrc: string; + selectedVideo: string | null; + pickMode: boolean; + previewCursor: string; +}) { + const windowTotalH = vpHeight + titlebarH; + let winX: number; + let winY: number; + if ( + windowCfg?.position && + typeof windowCfg.position === "object" && + "x" in windowCfg.position + ) { + winX = windowCfg.position.x; + winY = windowCfg.position.y; + } else { + winX = Math.round((canvasW - vpWidth) / 2); + winY = Math.round((canvasH - windowTotalH) / 2); + } + + const shadow = buildShadowStyle(windowCfg?.shadow); + const tbBg = windowCfg?.titlebar?.background ?? "#e8e8e8"; + const tbStoplight = windowCfg?.titlebar?.stoplight !== false; + const tbTitle = windowCfg?.titlebar?.title ?? ""; + + return ( +
+
+
+ {titlebarVisible && titlebarH > 0 && ( +
+ {tbStoplight && ( +
+ + + +
+ )} + {tbTitle && ( + + {tbTitle} + + )} +
+ )} +