diff --git a/app/(workbench)/app/page.tsx b/app/(workbench)/app/page.tsx new file mode 100644 index 0000000..15d8861 --- /dev/null +++ b/app/(workbench)/app/page.tsx @@ -0,0 +1,54 @@ +import { AppShell as AnalyzeWorkspace } from "@/components/app/AppShell"; +import { AppShell as WorkspaceShell } from "@/components/dashboard/app-shell"; +import { getEntitlementSummary } from "@/lib/server/entitlements"; +import { getCurrentUser } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type AnimationConverterProps = { + searchParams: Promise<{ project?: string; ws?: string }>; +}; + +/** + * The analyzer. Signed-in users get the workbench shell from the (workbench) + * layout; anonymous visitors fall through that layout bare, so they keep the + * standalone shell here. `?ws=` / `?project=` carry the save destination from + * "New analysis" / "New sequence" buttons in the workspace desktop. + */ +export default async function AnimationConverter({ + searchParams, +}: AnimationConverterProps) { + const user = await getCurrentUser(); + + if (!user) { + return ( + + + + ); + } + + const [summary, params] = await Promise.all([ + getEntitlementSummary(user.id), + searchParams, + ]); + + return ( + + ); +} + +function sanitizeResourceId(value: string | undefined) { + return value && UUID_PATTERN.test(value) ? value : null; +} diff --git a/app/(workbench)/layout.tsx b/app/(workbench)/layout.tsx index 774b48b..55249b1 100644 --- a/app/(workbench)/layout.tsx +++ b/app/(workbench)/layout.tsx @@ -29,7 +29,12 @@ export default async function WorkbenchLayout({ const tree = buildWorkspaceTree(data.workspaces, data.projects); return ( - + {children} ); diff --git a/app/(workbench)/projects/[projectId]/page.tsx b/app/(workbench)/projects/[projectId]/page.tsx index d4e949c..b76aa19 100644 --- a/app/(workbench)/projects/[projectId]/page.tsx +++ b/app/(workbench)/projects/[projectId]/page.tsx @@ -1,7 +1,12 @@ -import { CreateVersionForm } from "@/components/project/create-version-form"; +import { Sparkles } from "lucide-react"; +import Link from "next/link"; + import { ProjectHeader } from "@/components/project/project-header"; +import { SavedAnalysisViewer } from "@/components/project/saved-analysis-viewer"; +import { VersionDetail } from "@/components/project/version-detail"; import { VersionTimeline } from "@/components/project/version-timeline"; import { UpgradeGate } from "@/components/app/UpgradeGate"; +import { parseSavedAnalysisResult } from "@/lib/contracts/savedAnalysis"; import { getProjectPageData, @@ -30,10 +35,88 @@ export default async function ProjectPage({ params }: ProjectPageProps) { data.role === "owner" || (data.workspace.plan_tier === "studio" && data.role === "admin"); + const latestVersion = data.versions[0] ?? null; + const savedResult = latestVersion + ? parseSavedAnalysisResult(latestVersion.motion_spec) + : null; + const newSequenceHref = `/app?project=${data.project.id}`; + return (
+
+ + {canWrite ? ( + +
+ - {canWrite ? : null} + + {savedResult ? ( +
+
+

+ Latest sequence + {latestVersion ? ( + + v{latestVersion.version_number} + + ) : null} +

+
+ +
+ ) : latestVersion ? ( + + ) : ( +
+

+ No sequences yet +

+

+ Run an analysis and it will be saved here as this project's + first sequence — spec, preview, and generated code included. +

+ {canWrite ? ( + +
+ )} +
); diff --git a/app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx b/app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx index a59a182..79cafc4 100644 --- a/app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx +++ b/app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; import { ProjectHeader } from "@/components/project/project-header"; +import { SavedAnalysisViewer } from "@/components/project/saved-analysis-viewer"; import { VersionDetail } from "@/components/project/version-detail"; import { VersionTimeline } from "@/components/project/version-timeline"; import { UpgradeGate } from "@/components/app/UpgradeGate"; +import { parseSavedAnalysisResult } from "@/lib/contracts/savedAnalysis"; import { getVersionPageData, @@ -30,20 +32,55 @@ export default async function VersionPage({ params }: VersionPageProps) { return ; } const data = await getVersionPageData(projectId, versionId, user); + const savedResult = parseSavedAnalysisResult(data.version.motion_spec); return (
- - Back to project - + + {data.workspace.name} + + {" / "} + + {data.project.title} + + {" / "} + + v{data.version.version_number} + + + -
- - -
+ + {savedResult ? ( +
+

+ Sequence v{data.version.version_number} + {data.version.label ? ( + + {data.version.label} + + ) : null} +

+ +
+ ) : ( + + )} + +
); } diff --git a/app/(workbench)/projects/page.tsx b/app/(workbench)/projects/page.tsx index 9457529..b99b6a3 100644 --- a/app/(workbench)/projects/page.tsx +++ b/app/(workbench)/projects/page.tsx @@ -1,51 +1,10 @@ -import { ArrowUpRight, Sparkles } from "lucide-react"; -import Link from "next/link"; - -import { RecentProjects } from "@/components/dashboard/recent-projects"; -import { UpgradeGate } from "@/components/app/UpgradeGate"; - -import { - getDashboardData, - requireDashboardUser, - resolvePlanGate, -} from "@/app/dashboard/data"; - -export const dynamic = "force-dynamic"; - -export default async function ProjectsPage() { - const user = await requireDashboardUser("/projects"); - const { isPaid } = await resolvePlanGate(user.id); - if (!isPaid) { - return ; - } - const data = await getDashboardData(user); - - return ( -
-
-
-

- Project index -

-

- Saved motion work, ready to continue. -

-

- Open recent analysis projects, review source status, or start a fresh - motion pass in the analyzer. -

-
- -
- - -
- ); +import { redirect } from "next/navigation"; + +/** + * The standalone project index was folded into the workspace desktop — + * workspaces are the single organizing unit and projects live inside them. + * Kept as a redirect so old links and bookmarks stay alive. + */ +export default function ProjectsIndexRedirect() { + redirect("/workspaces"); } diff --git a/app/(workbench)/workspaces/[workspaceId]/page.tsx b/app/(workbench)/workspaces/[workspaceId]/page.tsx index d7adfb8..1500277 100644 --- a/app/(workbench)/workspaces/[workspaceId]/page.tsx +++ b/app/(workbench)/workspaces/[workspaceId]/page.tsx @@ -1,11 +1,13 @@ -import { CreateProjectForm } from "@/components/project/create-project-form"; +import { Sparkles } from "lucide-react"; +import Link from "next/link"; + +import { WorkspaceFiles } from "@/components/workspace/workspace-files"; import { WorkspaceHeader } from "@/components/workspace/workspace-header"; import { WorkspaceMembers } from "@/components/workspace/workspace-members"; -import { WorkspaceProjects } from "@/components/workspace/workspace-projects"; import { UpgradeGate } from "@/components/app/UpgradeGate"; import { - getWorkspacePageData, + getWorkspaceDesktopData, requireDashboardUser, resolvePlanGate, } from "@/app/dashboard/data"; @@ -25,14 +27,42 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) { if (!isPaid) { return ; } - const data = await getWorkspacePageData(workspaceId, user); + const data = await getWorkspaceDesktopData(workspaceId, user); + const newAnalysisHref = `/app?ws=${data.workspace.id}`; return (
+
+ + +
+ - +
- + ; } - const data = await getDashboardData(user); + const folders = await getWorkspaceFolders(); return (
-
-
-

- Workspace index -

-

- Teams, client spaces, and motion libraries. -

-

- Keep projects organized by workspace, then move straight into - analysis when a reference is ready. -

-
- -
- -
- +
+
+

+ Desktop +

+

+ Your workspaces. +

+

+ Every folder is a workspace. Open one to browse its saved analyses + — new runs are saved there automatically. +

- + +
+ +
+ +
+
); } diff --git a/app/app/page.tsx b/app/app/page.tsx deleted file mode 100644 index 9d31e91..0000000 --- a/app/app/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AppShell as AnalyzeWorkspace } from "@/components/app/AppShell"; -import { AppShell as WorkspaceShell } from "@/components/dashboard/app-shell"; -import { getEntitlementSummary } from "@/lib/server/entitlements"; -import { getCurrentUser } from "@/lib/supabase/server"; - -export const dynamic = "force-dynamic"; - -export default async function AnimationConverter() { - const user = await getCurrentUser(); - - if (!user) { - return ( - - - - ); - } - - const summary = await getEntitlementSummary(user.id); - - return ( - - - - ); -} diff --git a/app/dashboard/data.ts b/app/dashboard/data.ts index cf49c9f..c32103c 100644 --- a/app/dashboard/data.ts +++ b/app/dashboard/data.ts @@ -35,6 +35,17 @@ export type WorkspacePageData = { workspace: WorkspaceRow; }; +export type WorkspaceFolderSummary = { + projectCount: number; + recentProjects: Array>; + workspace: WorkspaceRow; +}; + +export type WorkspaceDesktopData = WorkspacePageData & { + /** Saved sequences (project versions) per project id. */ + sequenceCounts: Record; +}; + export type ProjectPageData = { project: ProjectRow; role: WorkspaceRole; @@ -125,6 +136,86 @@ export async function getDashboardData(user: Pick) { } satisfies DashboardData; } +/** + * Loads every workspace the user can see plus lightweight project rollups for + * the desktop (folder grid) view. RLS scopes both queries to the session. + */ +export async function getWorkspaceFolders(): Promise { + const supabase = await createSupabaseServerClient(); + + const [workspaces, projects] = await Promise.all([ + supabase + .from("workspaces") + .select("*") + .order("updated_at", { ascending: false }), + supabase + .from("projects") + .select("*") + .order("updated_at", { ascending: false }), + ]); + + if (workspaces.error || projects.error) { + throw new Error("Failed to load workspaces."); + } + + const byWorkspace = new Map(); + for (const project of projects.data ?? []) { + const bucket = byWorkspace.get(project.workspace_id); + if (bucket) { + bucket.push(project); + } else { + byWorkspace.set(project.workspace_id, [project]); + } + } + + return (workspaces.data ?? []).map((workspace) => { + const workspaceProjects = byWorkspace.get(workspace.id) ?? []; + return { + projectCount: workspaceProjects.length, + recentProjects: workspaceProjects.slice(0, 2).map((project) => ({ + id: project.id, + title: project.title, + updated_at: project.updated_at, + })), + workspace, + }; + }); +} + +/** + * Workspace page data plus how many saved sequences each project holds, for + * the opened-folder (files) view. + */ +export async function getWorkspaceDesktopData( + workspaceId: string, + user: Pick, +): Promise { + const data = await getWorkspacePageData(workspaceId, user); + const projectIds = data.projects.map((project) => project.id); + + if (projectIds.length === 0) { + return { ...data, sequenceCounts: {} }; + } + + const supabase = await createSupabaseServerClient(); + const { data: versions, error } = await supabase + .from("project_versions") + .select("project_id") + .in("project_id", projectIds); + + if (error) { + throw new Error("Failed to load workspace sequences."); + } + + const sequenceCounts: Record = {}; + for (const version of versions ?? []) { + sequenceCounts[version.project_id] = + (sequenceCounts[version.project_id] ?? 0) + 1; + } + + return { ...data, sequenceCounts }; +} + export async function getWorkspacePageData( workspaceId: string, user: Pick, diff --git a/components/app/AppShell.tsx b/components/app/AppShell.tsx index a9c7098..7e8a767 100644 --- a/components/app/AppShell.tsx +++ b/components/app/AppShell.tsx @@ -8,9 +8,14 @@ import { type DragEvent, type MouseEvent, } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { AppStatusBar } from "@/components/app/AppStatusBar"; +import { + DEFAULT_INTENT_COLOR, + intentColorFor, +} from "@/components/app/intent-colors"; import { FreeSaveNoticeModal } from "@/components/app/FreeSaveNoticeModal"; import { ProcessCanvas } from "@/components/app/ProcessCanvas"; import { AnalyzeStudio } from "@/components/app/studio/AnalyzeStudio"; @@ -27,6 +32,10 @@ import { CODE_TABS, type CodeTab } from "@/lib/generatedCode"; import type { MotionSpecEditableField } from "@/lib/motionSpecEditor"; import { updateAnalysisResultSpec } from "@/lib/motionSpecEditor"; import { incrementUsage, usagesLeft } from "@/lib/rateLimit"; +import { + saveAnalysisToWorkspace, + type SaveAnalysisTarget, +} from "@/lib/workbench/saveAnalysis"; import styles from "./AppShell.module.css"; import type { AnalysisStage } from "./types"; @@ -39,15 +48,6 @@ const DEFAULT_FRAME_COUNT = DEFAULT_ENTITLEMENTS.maxFramesPerAnalysis; const FREE_SAVE_ACK_SESSION_KEY = "motioncode_free_save_ack_session"; const FREE_SAVE_ACK_DISMISSED_KEY = "motioncode_free_save_ack_dismissed"; -const INTENT_COLORS: Record = { - entrance: "#9ef0c0", - exit: "#f58f7c", - hover: "#ffd166", - loading: "#d8cfbc", - loop: "#82e6a0", - morph: "#00ff88", -}; - const STATUS_MESSAGES = [ "Analyzing motion patterns...", "Reading easing curves...", @@ -55,6 +55,12 @@ const STATUS_MESSAGES = [ "Almost there...", ]; +type SaveState = + | { status: "idle" } + | { status: "saving" } + | { status: "saved"; projectId: string } + | { status: "error"; message: string }; + type AppShellProps = { initialDailyAnalysisUsage?: { limit: number; @@ -63,12 +69,19 @@ type AppShellProps = { }; initialEntitlements?: PlanEntitlements; initialPlanTier?: PlanTier; + /** + * Where completed analyses are saved (paid tiers). A projectId appends a new + * sequence to that project; a workspaceId creates a new project inside it. + * Empty target falls back to the user's most recent workspace. + */ + saveTarget?: SaveAnalysisTarget; }; export function AppShell({ initialDailyAnalysisUsage, initialEntitlements = DEFAULT_ENTITLEMENTS, initialPlanTier = DEFAULT_ENTITLEMENTS.tier, + saveTarget, }: AppShellProps = {}) { const router = useRouter(); const fileInputRef = useRef(null); @@ -135,7 +148,9 @@ export function AppShell({ const [freeSaveAcknowledged, setFreeSaveAcknowledged] = useState(readFreeSaveAcknowledged); const [freeSaveModalOpen, setFreeSaveModalOpen] = useState(false); + const [saveState, setSaveState] = useState({ status: "idle" }); const pendingFileRef = useRef(null); + const saveRunRef = useRef(0); const localUsageRemaining = usagesLeft(entitlements.dailyAnalyses); const usageRemaining = @@ -314,8 +329,39 @@ export function AppShell({ setValidationError(null); setFrames([]); setFrameThumbs([]); + setSaveState({ status: "idle" }); }, [handleRemoveFile]); + // Persist a completed analysis into the workbench (paid tiers only). Runs + // fire-and-forget after the result is on screen: a save failure never blocks + // the studio, it just surfaces as a "not saved" pill in the header. + const persistAnalysis = useCallback( + (analyzed: AnalysisResult) => { + if (userPlan === "free") { + return; + } + + const runId = saveRunRef.current + 1; + saveRunRef.current = runId; + setSaveState({ status: "saving" }); + + void saveAnalysisToWorkspace(analyzed, saveTarget).then((outcome) => { + if (saveRunRef.current !== runId) { + return; + } + if (outcome.ok) { + setSaveState({ status: "saved", projectId: outcome.projectId }); + // The workbench explorer tree is server-rendered; refresh so the new + // project/sequence shows up without a manual reload. + router.refresh(); + } else { + setSaveState({ status: "error", message: outcome.message }); + } + }); + }, + [router, saveTarget, userPlan], + ); + const updateFrameCount = useCallback( (count: number) => { setFrameCount(count); @@ -362,13 +408,21 @@ export function AppShell({ setActiveTab(getStoredTab() ?? "CSS"); setStage("done"); setShowToast(true); + persistAnalysis(analyzed); } catch (caught) { setError(caught instanceof Error ? caught.message : "Analysis failed. Try again."); setStage("error"); } finally { setLoading(false); } - }, [canUseFree, entitlements.dailyAnalyses, frames, loading, userPlan]); + }, [ + canUseFree, + entitlements.dailyAnalyses, + frames, + loading, + persistAnalysis, + userPlan, + ]); const onDragOver = useCallback((event: DragEvent) => { event.preventDefault(); @@ -511,8 +565,8 @@ export function AppShell({ }, [clearAnimationTimers, frames.length, stage, stepsList.length]); const intentColor = result - ? INTENT_COLORS[result.spec.intent.toLowerCase()] || "#00ff88" - : "#00ff88"; + ? intentColorFor(result.spec.intent) + : DEFAULT_INTENT_COLOR; const processStage: Exclude = stage === "done" ? "idle" : stage; const liveStatusMessage = @@ -592,6 +646,7 @@ export function AppShell({ onSpecChange={handleSpecChange} onTabChange={setActiveTab} result={result} + saveSlot={} />
)} @@ -623,6 +678,44 @@ export function AppShell({ } +function SaveStatePill({ saveState }: { saveState: SaveState }) { + if (saveState.status === "idle") { + return null; + } + + if (saveState.status === "saving") { + return ( + + Saving… + + ); + } + + if (saveState.status === "saved") { + return ( + +