-
+
;
}
- const data = await getDashboardData(user);
+ const folders = await getWorkspaceFolders();
return (
-
-
-
);
}
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 (
+
+
+ Saved · Open
+
+ );
+ }
+
+ return (
+
+ Not saved
+
+ );
+}
+
async function analyzeViaApi({
frames,
planTier,
diff --git a/components/app/Workbench.tsx b/components/app/Workbench.tsx
index bff866c..66a4059 100644
--- a/components/app/Workbench.tsx
+++ b/components/app/Workbench.tsx
@@ -1,6 +1,7 @@
"use client";
import {
+ Boxes,
CreditCard,
Gauge,
Lock,
@@ -14,6 +15,7 @@ import { usePathname } from "next/navigation";
import { useState, type ReactNode } from "react";
import { SignOutButton } from "@/components/auth/sign-out-button";
+import { PlanSync } from "@/components/dashboard/PlanSync";
import type { PlanTier } from "@/lib/contracts/plans";
import type { WorkspaceTreeNode } from "@/lib/workbench/tree";
import { cn } from "@/lib/utils";
@@ -23,15 +25,27 @@ import { ExplorerTree } from "./explorer/ExplorerTree";
type WorkbenchProps = {
tree: WorkspaceTreeNode[];
userEmail?: string | null;
+ /** Current user's id; enables live plan sync via Supabase Realtime. */
+ userId?: string | null;
children: ReactNode;
/** Plan tier of the current user; drives the lock badge on paid rail links. */
planTier?: PlanTier;
- /** Full-bleed main pane (no max-width/padding) for the Analyze studio. */
+ /**
+ * Full-bleed main pane (no max-width/padding). Defaults to true on /app so
+ * the Analyze studio owns the viewport; other routes scroll with padding.
+ */
bleed?: boolean;
};
const railItems = [
{ href: "/app", icon: Sparkles, label: "Analyze", match: "/app", paid: false },
+ {
+ href: "/workspaces",
+ icon: Boxes,
+ label: "Workspaces",
+ match: "/workspaces",
+ paid: true,
+ },
{
href: "/dashboard",
icon: Gauge,
@@ -86,19 +100,22 @@ function RailLink({
export function Workbench({
tree,
userEmail,
+ userId,
children,
planTier = "free",
- bleed = false,
+ bleed,
}: WorkbenchProps) {
const pathname = usePathname();
const [explorerOpen, setExplorerOpen] = useState(true);
const isFree = planTier === "free";
+ const isBleed = bleed ?? pathname === "/app";
const isActive = (match: string) =>
pathname === match || pathname.startsWith(`${match}/`);
return (
+
= {
+ entrance: "#9ef0c0",
+ exit: "#f58f7c",
+ hover: "#ffd166",
+ loading: "#d8cfbc",
+ loop: "#82e6a0",
+ morph: "#00ff88",
+};
+
+export const DEFAULT_INTENT_COLOR = "#00ff88";
+
+export function intentColorFor(intent: string) {
+ return INTENT_COLORS[intent.toLowerCase()] ?? DEFAULT_INTENT_COLOR;
+}
diff --git a/components/app/studio/AnalyzeStudio.tsx b/components/app/studio/AnalyzeStudio.tsx
index d6884b6..957b45b 100644
--- a/components/app/studio/AnalyzeStudio.tsx
+++ b/components/app/studio/AnalyzeStudio.tsx
@@ -1,7 +1,14 @@
"use client";
import { PanelLeftClose, SlidersHorizontal } from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { MotionSpecPanel } from "@/components/app/MotionSpecPanel";
@@ -35,6 +42,8 @@ type AnalyzeStudioProps = {
onTabChange: (tab: CodeTab) => void;
onNewAnalysis: () => void;
onSpecChange: (field: MotionSpecEditableField, value: unknown) => void;
+ /** Optional status pill rendered in the header (e.g. workspace save state). */
+ saveSlot?: ReactNode;
};
function seedFromResult(result: AnalysisResult): Record {
@@ -59,6 +68,7 @@ export function AnalyzeStudio({
onTabChange,
onNewAnalysis,
onSpecChange,
+ saveSlot,
}: AnalyzeStudioProps) {
// Seeded from the result via the useState initializer. AppShell remounts this
// component (key={result.id}) on each new analysis, so no seeding effect is needed.
@@ -224,6 +234,7 @@ export function AnalyzeStudio({
+ {saveSlot}