- {pkg.tags.map((tag) => (
-
- {tag}
-
- ))}
+ {/* Preview */}
+
+ {theme.preview}
+ {selected && locked && (
+
+
+
+ )}
+ {selected && !locked && (
+
+
+
+ )}
-
- Best for: {pkg.bestFor.join(", ")}
-
+ {/* Info */}
+
+
+
{theme.description}
+
+ {theme.tags.map(t => (
+ {t}
+ ))}
+
+
);
}
// ---------------------------------------------------------------------------
-// App section
+// Surface section
// ---------------------------------------------------------------------------
-function AppSection({
- appName,
- selectedPackageId,
+function SurfaceSection({
+ surface,
+ selectedThemeId,
+ lockedThemeId,
onSelect,
+ onLock,
+ onUnlock,
+ saving,
}: {
- appName: string;
- selectedPackageId: string | undefined;
- onSelect: (appName: string, pkgId: string) => Promise
;
+ surface: Surface;
+ selectedThemeId: string | null;
+ lockedThemeId: string | null;
+ onSelect: (themeId: string) => void;
+ onLock: () => void;
+ onUnlock: () => void;
+ saving: boolean;
}) {
- const [saving, setSaving] = useState(null);
-
- const handleSelect = async (pkgId: string) => {
- if (pkgId === selectedPackageId) return;
- setSaving(pkgId);
- await onSelect(appName, pkgId);
- setSaving(null);
- };
-
- const selectedPkg = PACKAGES.find((p) => p.id === selectedPackageId);
+ const Icon = surface.icon;
+ const lockedTheme = surface.themes.find(t => t.id === lockedThemeId);
return (
-
+
+ {/* Surface header */}
-
-
-
+
+
+
-
{appName}
- {selectedPkg && (
-
- Using {selectedPkg.name}
-
- )}
+
+
{surface.name}
+ {lockedTheme && (
+
+
+ {lockedTheme.name}
+
+ )}
+
+
{surface.description}
- {selectedPkg && (
-
e.stopPropagation()}
- >
- Docs
-
-
- )}
+
+ {/* Lock / Unlock controls */}
+
+ {lockedThemeId ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Theme cards — horizontal scroll */}
+ {!lockedThemeId && (
+
+ {surface.themes.map(theme => (
+ onSelect(theme.id)}
+ />
+ ))}
+
+ )}
+
+ {/* Locked state — show only selected */}
+ {lockedThemeId && (
+
+ {surface.themes.map(theme => (
+ {}}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Phase 1 — Surface picker
+// ---------------------------------------------------------------------------
+
+function SurfacePicker({
+ onConfirm,
+ saving,
+}: {
+ onConfirm: (ids: string[]) => void;
+ saving: boolean;
+}) {
+ const [selected, setSelected] = useState
>(new Set());
+
+ const toggle = (id: string) => {
+ setSelected(prev => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ };
+
+ return (
+
+
+
Design surfaces
+
+ Which surfaces does your product need? Select all that apply — you can always add more later.
+
- {PACKAGES.map((pkg) => (
-
handleSelect(pkg.id)}
- />
- ))}
+ {ALL_SURFACES.map(surface => {
+ const Icon = surface.icon;
+ const isSelected = selected.has(surface.id);
+ return (
+
+ );
+ })}
+
+
+
+
+ {selected.size === 0 && (
+
Select at least one surface to continue
+ )}
);
@@ -210,39 +550,73 @@ export default function DesignPage({
}) {
const { projectId } = use(params);
- const [apps, setApps] = useState<{ name: string; path: string }[]>([]);
- const [designPackages, setDesignPackages] = useState>({});
- const [giteaRepo, setGiteaRepo] = useState(null);
+ const [surfaces, setSurfaces] = useState([]);
+ const [surfaceThemes, setSurfaceThemes] = useState>({});
+ const [selectedThemes, setSelectedThemes] = useState>({});
+ const [savingLock, setSavingLock] = useState(null);
+ const [savingSurfaces, setSavingSurfaces] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
- fetch(`/api/projects/${projectId}/apps`)
- .then((r) => r.json())
- .then((d) => {
- setApps(d.apps ?? []);
- setDesignPackages(d.designPackages ?? {});
- setGiteaRepo(d.giteaRepo ?? null);
+ fetch(`/api/projects/${projectId}/design-surfaces`)
+ .then(r => r.json())
+ .then(d => {
+ setSurfaces(d.surfaces ?? []);
+ setSurfaceThemes(d.surfaceThemes ?? {});
+ setSelectedThemes(d.surfaceThemes ?? {});
})
- .catch(() => toast.error("Failed to load apps"))
+ .catch(() => toast.error("Failed to load design data"))
.finally(() => setLoading(false));
}, [projectId]);
- const handleSelect = async (appName: string, packageId: string) => {
+ const handleConfirmSurfaces = async (ids: string[]) => {
+ setSavingSurfaces(true);
try {
- const res = await fetch(`/api/projects/${projectId}/apps`, {
+ const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ appName, packageId }),
+ body: JSON.stringify({ surfaces: ids }),
});
- if (!res.ok) throw new Error("Save failed");
- setDesignPackages((prev) => ({ ...prev, [appName]: packageId }));
- const pkg = PACKAGES.find((p) => p.id === packageId);
- toast.success(`${appName} → ${pkg?.name ?? packageId}`);
+ if (!res.ok) throw new Error();
+ setSurfaces(ids);
+ toast.success("Surfaces saved");
} catch {
- toast.error("Failed to save selection");
+ toast.error("Failed to save surfaces");
+ } finally {
+ setSavingSurfaces(false);
}
};
+ const handleLock = async (surfaceId: string) => {
+ const themeId = selectedThemes[surfaceId];
+ if (!themeId) return;
+ setSavingLock(surfaceId);
+ try {
+ const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ surface: surfaceId, theme: themeId }),
+ });
+ if (!res.ok) throw new Error();
+ setSurfaceThemes(prev => ({ ...prev, [surfaceId]: themeId }));
+ const surface = ALL_SURFACES.find(s => s.id === surfaceId);
+ const theme = surface?.themes.find(t => t.id === themeId);
+ toast.success(`${surface?.name} → ${theme?.name} locked in`);
+ } catch {
+ toast.error("Failed to lock in theme");
+ } finally {
+ setSavingLock(null);
+ }
+ };
+
+ const handleUnlock = (surfaceId: string) => {
+ setSurfaceThemes(prev => {
+ const next = { ...prev };
+ delete next[surfaceId];
+ return next;
+ });
+ };
+
if (loading) {
return (
@@ -251,54 +625,61 @@ export default function DesignPage({
);
}
+ // Phase 1 — no surfaces set yet
+ if (surfaces.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // Phase 2 — surfaces set, pick themes
+ const activeSurfaces = ALL_SURFACES.filter(s => surfaces.includes(s.id));
+ const lockedCount = Object.keys(surfaceThemes).length;
+
return (
- {/* App sections */}
- {apps.length === 0 ? (
-
-
-
No apps found
-
- Push a Turborepo scaffold to your Gitea repo — apps in the{" "}
- apps/ directory will appear here.
-
-
- ) : (
-
- {apps.map((app) => (
-
- ))}
-
- )}
+
+ {activeSurfaces.map((surface, i) => (
+
0 ? "pt-10" : ""}>
+ setSelectedThemes(prev => ({ ...prev, [surface.id]: themeId }))}
+ onLock={() => handleLock(surface.id)}
+ onUnlock={() => handleUnlock(surface.id)}
+ saving={savingLock === surface.id}
+ />
+
+ ))}
+
);
}
diff --git a/app/api/projects/[projectId]/design-surfaces/route.ts b/app/api/projects/[projectId]/design-surfaces/route.ts
new file mode 100644
index 0000000..8ef7b81
--- /dev/null
+++ b/app/api/projects/[projectId]/design-surfaces/route.ts
@@ -0,0 +1,84 @@
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth/authOptions';
+import { query } from '@/lib/db-postgres';
+
+async function getProject(projectId: string, email: string) {
+ const rows = await query<{ data: Record }>(
+ `SELECT p.data FROM fs_projects p
+ JOIN fs_users u ON u.id = p.user_id
+ WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
+ [projectId, email]
+ );
+ return rows[0] ?? null;
+}
+
+/**
+ * GET — returns surfaces[] and surfaceThemes{} for the project.
+ * surfaces: string[] — which design surfaces are active (set by Atlas or manually)
+ * surfaceThemes: Record — locked-in theme per surface
+ */
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ projectId: string }> }
+) {
+ const { projectId } = await params;
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const row = await getProject(projectId, session.user.email);
+ if (!row) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
+
+ const data = row.data ?? {};
+ return NextResponse.json({
+ surfaces: (data.surfaces ?? []) as string[],
+ surfaceThemes: (data.surfaceThemes ?? {}) as Record,
+ });
+}
+
+/**
+ * PATCH — two operations:
+ * { surfaces: string[] } — save the active surface list
+ * { surface: string, theme: string } — lock in a theme for one surface
+ */
+export async function PATCH(
+ req: Request,
+ { params }: { params: Promise<{ projectId: string }> }
+) {
+ const { projectId } = await params;
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const body = await req.json() as
+ | { surfaces: string[] }
+ | { surface: string; theme: string };
+
+ if ('surfaces' in body) {
+ // Save the surface list
+ await query(
+ `UPDATE fs_projects p
+ SET data = data || jsonb_build_object('surfaces', $3::jsonb),
+ updated_at = NOW()
+ FROM fs_users u
+ WHERE p.id = $1 AND p.user_id = u.id AND u.data->>'email' = $2`,
+ [projectId, session.user.email, JSON.stringify(body.surfaces)]
+ );
+ } else if ('surface' in body && 'theme' in body) {
+ // Lock in a theme for one surface
+ await query(
+ `UPDATE fs_projects p
+ SET data = data || jsonb_build_object(
+ 'surfaceThemes',
+ COALESCE(data->'surfaceThemes', '{}'::jsonb) || jsonb_build_object($3, $4)
+ ),
+ updated_at = NOW()
+ FROM fs_users u
+ WHERE p.id = $1 AND p.user_id = u.id AND u.data->>'email' = $2`,
+ [projectId, session.user.email, body.surface, body.theme]
+ );
+ } else {
+ return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
+ }
+
+ return NextResponse.json({ success: true });
+}