diff --git a/bun.lock b/bun.lock index 22d85f2c8..aae8f7dc3 100644 --- a/bun.lock +++ b/bun.lock @@ -121,7 +121,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -135,6 +135,7 @@ "@codemirror/view": "^6.40.0", "@hyperframes/core": "workspace:*", "@phosphor-icons/react": "^2.1.10", + "@use-gesture/react": "^10.3.1", "codemirror": "^6.0.1", }, "devDependencies": { @@ -143,9 +144,12 @@ "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", + "puppeteer-core": "^24.40.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", "vite": "^5.0.0", + "vitest": "^3.2.4", + "zustand": "^5.0.0", }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -667,6 +671,10 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "22.19.15" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "7.29.0", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "7.20.5", "react-refresh": "0.17.0" }, "peerDependencies": { "vite": "5.4.21" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@bcoe/v8-coverage": "1.0.2", "ast-v8-to-istanbul": "0.3.12", "debug": "4.4.3", "istanbul-lib-coverage": "3.2.2", "istanbul-lib-report": "3.0.1", "istanbul-lib-source-maps": "5.0.6", "istanbul-reports": "3.2.0", "magic-string": "0.30.21", "magicast": "0.3.5", "std-env": "3.10.0", "test-exclude": "7.0.2", "tinyrainbow": "2.0.0" }, "peerDependencies": { "vitest": "3.2.4" } }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], @@ -1361,8 +1369,6 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.1" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "18.3.1" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.8", "rollup": "4.59.0" }, "optionalDependencies": { "@types/node": "22.19.15", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], @@ -1419,7 +1425,7 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@types/react": "19.2.14", "react": "18.3.1" } }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], diff --git a/packages/studio/.gitignore b/packages/studio/.gitignore new file mode 100644 index 000000000..9012af4f9 --- /dev/null +++ b/packages/studio/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +data/projects/ diff --git a/packages/studio/package.json b/packages/studio/package.json index 7afd1152c..b4e94b2a8 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/studio", - "version": "0.1.2", + "version": "0.1.3", "files": [ "src", "dist" @@ -15,7 +15,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", @@ -30,6 +32,7 @@ "@codemirror/view": "^6.40.0", "@hyperframes/core": "workspace:*", "@phosphor-icons/react": "^2.1.10", + "@use-gesture/react": "^10.3.1", "codemirror": "^6.0.1" }, "devDependencies": { @@ -38,9 +41,12 @@ "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", + "puppeteer-core": "^24.40.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^3.2.4", + "zustand": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e8f3022fa..2d0c02974 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,7 +1,13 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, type ReactNode } from "react"; import { NLELayout } from "./components/nle/NLELayout"; import { SourceEditor } from "./components/editor/SourceEditor"; import { FileTree } from "./components/editor/FileTree"; +import { CompositionThumbnail } from "./player/components/CompositionThumbnail"; +import { TimelineToolbar } from "./components/timeline/TimelineToolbar"; +import { usePlayerStore } from "./player/store/playerStore"; +import { EditModal } from "./components/timeline/EditModal"; +import { VideoThumbnail } from "./player/components/VideoThumbnail"; +import type { TimelineElement } from "./player/store/playerStore"; import { XIcon, CodeIcon, @@ -98,8 +104,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () ) : ( -
- +
+
)}
@@ -139,8 +145,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () {f.file &&

{f.file}

} {f.fixHint && (
- -

{f.fixHint}

+ +

{f.fixHint}

)}
@@ -156,8 +162,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () {f.file &&

{f.file}

} {f.fixHint && (
- -

{f.fixHint}

+ +

{f.fixHint}

)}
@@ -202,7 +208,69 @@ export function StudioApp() { const [editingFile, setEditingFile] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [fileTree, setFileTree] = useState([]); + const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); + + const renderClipContent = useCallback( + (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { + const pid = projectIdRef.current; + if (!pid) return null; + + // Resolve composition source path using the compIdToSrc map + let compSrc = el.compositionSrc; + if (compSrc && compIdToSrc.size > 0) { + const resolved = + compIdToSrc.get(el.id) || + compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, "")); + if (resolved) compSrc = resolved; + } + + if (compSrc) { + const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`; + return ( + + ); + } + + if ((el.tag === "video" || el.tag === "img") && el.src) { + const mediaSrc = el.src.startsWith("http") + ? el.src + : `/api/projects/${pid}/preview/${el.src}`; + return ( + + ); + } + + // HTML scene divs — render from index.html at the scene's time + if (el.tag === "div" && el.duration > 0) { + const previewUrl = `/api/projects/${pid}/preview`; + return ( + + ); + } + + return null; + }, + [compIdToSrc], + ); const [lintModal, setLintModal] = useState(null); + const [editModalOpen, setEditModalOpen] = useState(false); const [linting, setLinting] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [renderState, setRenderState] = useState<"idle" | "rendering" | "complete" | "error">( @@ -212,6 +280,7 @@ export function StudioApp() { const [_renderError, setRenderError] = useState(null); const refreshTimerRef = useRef | null>(null); const projectIdRef = useRef(projectId); + const previewIframeRef = useRef(null); // Listen for external file changes (user editing HTML outside the editor) useEffect(() => { @@ -458,6 +527,12 @@ export function StudioApp() { activeCompositionPath={ editingFile?.path?.startsWith("compositions/") ? editingFile.path : null } + renderClipContent={renderClipContent} + onCompIdToSrcChange={setCompIdToSrc} + onIframeRef={(iframe) => { + previewIframeRef.current = iframe; + }} + timelineToolbar={ setEditModalOpen(true)} />} /> @@ -481,7 +556,7 @@ export function StudioApp() { @@ -552,6 +627,9 @@ export function StudioApp() { {/* Lint modal */} {lintModal !== null && setLintModal(null)} />} + + {/* Edit modal */} + {editModalOpen && setEditModalOpen(false)} />} ); } diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index a631216f4..098b93db8 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -26,7 +26,9 @@ function getLanguageExtension(language: string) { case "js": case "typescript": case "ts": - return javascript({ typescript: language === "typescript" || language === "ts" }); + return javascript({ + typescript: language === "typescript" || language === "ts", + }); default: return html(); } diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 8faadbac1..48752bf40 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react"; +import { useState, useCallback, useRef, memo, type ReactNode } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; import { NLEPreview } from "./NLEPreview"; @@ -9,7 +10,9 @@ interface NLELayoutProps { portrait?: boolean; /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */ previewOverlay?: ReactNode; - /** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */ + /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ + timelineToolbar?: ReactNode; + /** Slot rendered below the timeline tracks */ timelineFooter?: ReactNode; /** Increment to force the preview to reload (e.g., after file writes) */ refreshKey?: number; @@ -17,6 +20,15 @@ interface NLELayoutProps { activeCompositionPath?: string | null; /** Callback to expose the iframe ref (for element picker, etc.) */ onIframeRef?: (iframe: HTMLIFrameElement | null) => void; + /** Callback when the viewed composition changes (drill-down/back) */ + onCompositionChange?: (compositionPath: string | null) => void; + /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ + renderClipContent?: ( + element: TimelineElement, + style: { clip: string; label: string }, + ) => ReactNode; + /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ + onCompIdToSrcChange?: (map: Map) => void; } const MIN_TIMELINE_H = 100; @@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({ projectId, portrait, previewOverlay, + timelineToolbar, timelineFooter, refreshKey, activeCompositionPath, onIframeRef, + onCompositionChange, + renderClipContent, + onCompIdToSrcChange, }: NLELayoutProps) { const { iframeRef, @@ -38,8 +54,17 @@ export const NLELayout = memo(function NLELayout({ seek, onIframeLoad: baseOnIframeLoad, saveSeekPosition, + resetPlayer, } = useTimelinePlayer(); + // Reset timeline state when the project changes to prevent stale data from a + // previous project leaking into the new one. + const prevProjectIdRef = useRef(null); + if (prevProjectIdRef.current !== projectId) { + prevProjectIdRef.current = projectId; + resetPlayer(); + } + // Preserve seek position when refreshKey changes (iframe will remount via key prop). const prevRefreshKeyRef = useRef(refreshKey); if (refreshKey !== prevRefreshKeyRef.current) { @@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({ // Composition ID → actual file path mapping, built from the raw index.html const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); - useEffect(() => { + useMountEffect(() => { fetch(`/api/projects/${projectId}/files/index.html`) .then((r) => r.json()) .then((data: { content?: string }) => { @@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({ if (id && src) map.set(id, src); } setCompIdToSrc(map); + onCompIdToSrcChange?.(map); }) .catch(() => {}); - }, [projectId]); + }); // Composition drill-down stack const [compositionStack, setCompositionStack] = useState([ { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` }, ]); + // Wrap setCompositionStack to auto-notify parent on composition change + const onCompositionChangeRef = useRef(onCompositionChange); + onCompositionChangeRef.current = onCompositionChange; + const updateCompositionStack: typeof setCompositionStack = useCallback((action) => { + setCompositionStack((prev) => { + const next = typeof action === "function" ? action(prev) : action; + const id = next[next.length - 1]?.id; + queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id)); + return next; + }); + }, []); + // Resizable timeline height const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H); const isDragging = useRef(false); @@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().setElements([]); // Toggle: if already viewing this composition, go back to parent (like Premiere) - setCompositionStack((prev) => { + updateCompositionStack((prev) => { const currentId = prev[prev.length - 1].id; if (currentId === resolvedPath && prev.length > 1) { return prev.slice(0, -1); @@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({ return [...prev, { id: resolvedPath, label, previewUrl }]; }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep + // eslint-disable-next-line react-hooks/exhaustive-deps [projectId, compIdToSrc], ); // Navigate back to a specific breadcrumb level const handleNavigateComposition = useCallback((index: number) => { usePlayerStore.getState().setElements([]); - setCompositionStack((prev) => prev.slice(0, index + 1)); + updateCompositionStack((prev) => prev.slice(0, index + 1)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Navigate to a composition when activeCompositionPath changes @@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({ prevActiveCompRef.current = activeCompositionPath; queueMicrotask(() => usePlayerStore.getState().setElements([])); if (activeCompositionPath === "index.html") { - setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev)); + updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev)); } else if (activeCompositionPath.startsWith("compositions/")) { const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, ""); const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`; - setCompositionStack((prev) => { + updateCompositionStack((prev) => { if (prev[prev.length - 1].id === activeCompositionPath) return prev; return [ { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` }, @@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && compositionStack.length > 1) { - setCompositionStack((prev) => prev.slice(0, -1)); + updateCompositionStack((prev) => prev.slice(0, -1)); } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [compositionStack.length], ); @@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({ onDoubleClick={(e) => { if ((e.target as HTMLElement).closest("[data-clip]")) return; if (compositionStack.length > 1) { - setCompositionStack((prev) => prev.slice(0, -1)); + updateCompositionStack((prev) => prev.slice(0, -1)); } }} > - + {timelineToolbar} + {timelineFooter} diff --git a/packages/studio/src/components/timeline/EditModal.tsx b/packages/studio/src/components/timeline/EditModal.tsx new file mode 100644 index 000000000..1823e9998 --- /dev/null +++ b/packages/studio/src/components/timeline/EditModal.tsx @@ -0,0 +1,209 @@ +import { useState, useCallback, useMemo } from "react"; +import { usePlayerStore } from "../../player/store/playerStore"; + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}:${s.toFixed(1).padStart(4, "0")}` : `${s.toFixed(1)}s`; +} + +interface EditModalProps { + onClose: () => void; +} + +export function EditModal({ onClose }: EditModalProps) { + const elements = usePlayerStore((s) => s.elements); + const currentTime = usePlayerStore((s) => s.currentTime); + const duration = usePlayerStore((s) => s.duration); + const selectedId = usePlayerStore((s) => s.selectedElementId); + + const [rangeStart, setRangeStart] = useState(() => { + // Default: if an element is selected, use its start. Otherwise use playhead. + const sel = elements.find((e) => e.id === selectedId); + return sel ? sel.start : Math.max(0, currentTime - 1); + }); + const [rangeEnd, setRangeEnd] = useState(() => { + const sel = elements.find((e) => e.id === selectedId); + return sel ? sel.start + sel.duration : Math.min(duration, currentTime + 5); + }); + const [prompt, setPrompt] = useState(""); + const [copied, setCopied] = useState(false); + + const elementsInRange = useMemo(() => { + const start = Math.min(rangeStart, rangeEnd); + const end = Math.max(rangeStart, rangeEnd); + return elements.filter((el) => { + const elEnd = el.start + el.duration; + return el.start < end && elEnd > start; + }); + }, [elements, rangeStart, rangeEnd]); + + const buildClipboardText = useCallback(() => { + const start = Math.min(rangeStart, rangeEnd); + const end = Math.max(rangeStart, rangeEnd); + + const elementLines = elementsInRange + .map( + (el) => + `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`, + ) + .join("\n"); + + return `Edit the following HyperFrames composition: + +Time range: ${formatTime(start)} — ${formatTime(end)} + +Elements in range: +${elementLines} + +User request: +${prompt.trim() || "(no prompt provided)"} + +Instructions: +Modify only the elements listed above within the specified time range. +The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations. +Preserve all other elements and timing outside this range.`; + }, [rangeStart, rangeEnd, elementsInRange, prompt]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(buildClipboardText()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback + const textarea = document.createElement("textarea"); + textarea.value = buildClipboardText(); + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [buildClipboardText]); + + const start = Math.min(rangeStart, rangeEnd); + const end = Math.max(rangeStart, rangeEnd); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

Edit Range

+

+ {formatTime(start)} — {formatTime(end)} +

+
+ +
+ + {/* Range sliders */} +
+
+ + +
+
+ + {/* Elements in range */} +
+

+ {elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""} in range +

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

No elements in this range

+ ) : ( +
+ {elementsInRange.map((el) => ( +
+ #{el.id} + ({el.tag}) + + {formatTime(el.start)}–{formatTime(el.start + el.duration)} + +
+ ))} +
+ )} +
+ + {/* Prompt */} +
+