From 41498ebceb7761bf2f07f182b5e797ef74a0c200 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 21 Sep 2026 08:25:26 +0000 Subject: [PATCH 01/18] feat(workspace): add navigation and metadata views --- src/App.tsx | 5 + src/components/layout/AppLayout.tsx | 6 +- src/lib/route-modules.ts | 2 + src/lib/workspace/client.ts | 80 +++++++++++ src/locales/en.json | 28 ++++ src/locales/es.json | 28 ++++ src/locales/hu.json | 28 ++++ src/pages/WorkspacePage.tsx | 93 +++++++++++++ src/pages/WorkspacesPage.tsx | 133 ++++++++++++++++++ src/test/workspace/navigation.test.tsx | 186 +++++++++++++++++++++++++ 10 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 src/lib/workspace/client.ts create mode 100644 src/pages/WorkspacePage.tsx create mode 100644 src/pages/WorkspacesPage.tsx create mode 100644 src/test/workspace/navigation.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 10237c792..07ea5bef7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,8 @@ const LandingPage = lazy(routeLoaders["/"]); const TermsPage = lazy(routeLoaders["/terms"]); const PrivacyPage = lazy(routeLoaders["/privacy"]); const HomePage = lazy(routeLoaders["/app"]); +const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]); +const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]); const GeneratePage = lazy(routeLoaders["/app/generate"]); const AttackPage = lazy(routeLoaders["/app/attack"]); const BrainstormPage = lazy(routeLoaders["/app/brainstorm"]); @@ -93,6 +95,7 @@ const ROUTE_FALLBACKS: Record = { "/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" }, "/login": { variant: "public", label: "Sign in", labelKey: "login.title" }, "/app": { variant: "home-grid", label: "Home", labelKey: "nav.home" }, + "/app/workspaces": { variant: "settings/list", label: "Workspaces", labelKey: "nav.workspaces" }, "/app/generate": { variant: "tool-form", label: "Generate", labelKey: "nav.generate" }, "/app/attack": { variant: "tool-form", label: "Attack", labelKey: "nav.attack" }, "/app/brainstorm": { variant: "tool-form", label: "Brainstorm", labelKey: "nav.brainstorm" }, @@ -235,6 +238,8 @@ function AppRoutes() { } > } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 28567a438..c41ae5a53 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -18,7 +18,8 @@ import { Dumbbell, Database, GitBranch, - Crosshair + Crosshair, + Files, } from 'lucide-react'; import { NavLink } from '@/components/NavLink'; import { Button } from '@/components/ui/button'; @@ -36,6 +37,7 @@ import { const navItems = [ { path: '/app', key: 'home', icon: Home }, + { path: '/app/workspaces', key: 'workspaces', icon: Files }, { path: '/app/generate', key: 'generate', icon: Sparkles }, { path: '/app/attack', key: 'attack', icon: Swords }, { path: '/app/brainstorm', key: 'brainstorm', icon: Lightbulb }, @@ -50,7 +52,7 @@ const navItems = [ ]; const primaryNavItems = navItems.filter((item) => - ['home', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key), + ['home', 'workspaces', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key), ); const moreNavItems = navItems.filter((item) => !primaryNavItems.includes(item)); diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts index 58d4620a0..c2199a946 100644 --- a/src/lib/route-modules.ts +++ b/src/lib/route-modules.ts @@ -20,6 +20,8 @@ export const routeLoaders = { "/login": cacheLoader(() => import("@/pages/LoginPage")), "/share/:code": cacheLoader(() => import("@/pages/SharedPage")), "/app": cacheLoader(() => import("@/pages/HomePage")), + "/app/workspaces": cacheLoader(() => import("@/pages/WorkspacesPage")), + "/app/workspaces/:workspaceId": cacheLoader(() => import("@/pages/WorkspacePage")), "/app/generate": cacheLoader(() => import("@/pages/GeneratePage")), "/app/attack": cacheLoader(() => import("@/pages/AttackPage")), "/app/brainstorm": cacheLoader(() => import("@/pages/BrainstormPage")), diff --git a/src/lib/workspace/client.ts b/src/lib/workspace/client.ts new file mode 100644 index 000000000..353ed3893 --- /dev/null +++ b/src/lib/workspace/client.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { supabase } from "@/lib/supabase/client"; + +const workspaceSummarySchema = z.object({ + id: z.string().uuid(), + motion: z.string(), + status: z.enum(["active", "archived", "deleted"]), + title: z.string().nullable(), + updated_at: z.string(), +}); + +export type WorkspaceSummary = z.infer; + +const workspaceSchema = z.object({ + access_epoch: z.number().int().nonnegative(), + id: z.string().uuid(), + motion: z.string(), + owner_id: z.string().uuid(), + revision: z.number().int().nonnegative(), + status: z.enum(["active", "archived", "deleted"]), + title: z.string().nullable(), +}); + +const workspaceMemberSchema = z.object({ + joined_at: z.string(), + user_id: z.string().uuid(), +}); + +const workspaceDocumentSchema = z.object({ + assignee_id: z.string().uuid().nullable(), + id: z.string().uuid(), + position: z.number().int().nonnegative(), + side: z.enum(["pro", "con"]), + state: z.enum(["active", "archived", "trashed"]), + title: z.string(), +}); + +export type WorkspaceBundle = { + workspace: z.infer; + members: z.infer[]; + documents: z.infer[]; +}; + +export async function listWorkspaces(): Promise { + const { data, error } = await supabase + .from("workspaces") + .select("id,motion,status,title,updated_at") + .order("updated_at", { ascending: false }); + if (error) throw error; + return workspaceSummarySchema.array().parse(data ?? []); +} + +export async function getWorkspaceBundle(workspaceId: string): Promise { + const [workspaceResult, membersResult, documentsResult] = await Promise.all([ + supabase + .from("workspaces") + .select("access_epoch,id,motion,owner_id,revision,status,title") + .eq("id", workspaceId) + .single(), + supabase + .from("workspace_members") + .select("joined_at,user_id") + .eq("workspace_id", workspaceId), + supabase + .from("workspace_documents") + .select("assignee_id,id,position,side,state,title") + .eq("workspace_id", workspaceId), + ]); + + if (workspaceResult.error) throw workspaceResult.error; + if (membersResult.error) throw membersResult.error; + if (documentsResult.error) throw documentsResult.error; + + return { + workspace: workspaceSchema.parse(workspaceResult.data), + members: workspaceMemberSchema.array().parse(membersResult.data ?? []), + documents: workspaceDocumentSchema.array().parse(documentsResult.data ?? []) + .sort((left, right) => left.position - right.position), + }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 3c826a26e..958f00ab6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,6 +1,7 @@ { "nav": { "home": "Home", + "workspaces": "Workspaces", "generate": "Generate", "attack": "Attack", "brainstorm": "Brainstorm", @@ -26,6 +27,7 @@ }, "navDesc": { "home": "Choose a mode", + "workspaces": "Write cases together", "generate": "Build arguments", "attack": "Find weaknesses", "brainstorm": "Explore ideas", @@ -38,6 +40,32 @@ "flowchart": "Map arguments", "history": "Saved items" }, + "workspaces": { + "title": "Workspaces", + "new": "New workspace", + "motion": "Motion", + "motionPlaceholder": "Schools should ban phones", + "create": "Create workspace", + "creating": "Creating...", + "createFailed": "Workspace creation failed. Try again.", + "yours": "Your workspaces", + "loading": "Loading workspaces...", + "loadFailed": "Workspaces could not be loaded.", + "empty": "Create your first workspace above.", + "active": "Active", + "archived": "Archived", + "loadingOne": "Opening workspace...", + "unavailable": "Workspace unavailable.", + "editorTitle": "Debate workspace", + "side": "Side", + "proSide": "For", + "conSide": "Against", + "files": "Files", + "assignedTo": "Assigned to {{member}}", + "assigned": "Assigned", + "document": "Document", + "documentEditor": "Document editor" + }, "common": { "export": "Export", "pdf": "PDF", diff --git a/src/locales/es.json b/src/locales/es.json index edb11107a..936b1efe7 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1,6 +1,7 @@ { "nav": { "home": "Inicio", + "workspaces": "Espacios", "generate": "Generar", "attack": "Atacar", "brainstorm": "Ideas", @@ -26,6 +27,7 @@ }, "navDesc": { "home": "Elegir un modo", + "workspaces": "Redactar casos en equipo", "generate": "Construir argumentos", "attack": "Encontrar debilidades", "brainstorm": "Explorar ideas", @@ -38,6 +40,32 @@ "flowchart": "Mapear argumentos", "history": "Elementos guardados" }, + "workspaces": { + "title": "Espacios de trabajo", + "new": "Nuevo espacio", + "motion": "Moción", + "motionPlaceholder": "Los colegios deberían prohibir los móviles", + "create": "Crear espacio", + "creating": "Creando...", + "createFailed": "No se pudo crear el espacio. Inténtalo de nuevo.", + "yours": "Tus espacios", + "loading": "Cargando espacios...", + "loadFailed": "No se pudieron cargar los espacios.", + "empty": "Crea tu primer espacio arriba.", + "active": "Activo", + "archived": "Archivado", + "loadingOne": "Abriendo el espacio...", + "unavailable": "Espacio no disponible.", + "editorTitle": "Espacio de debate", + "side": "Lado", + "proSide": "A favor", + "conSide": "En contra", + "files": "Archivos", + "assignedTo": "Asignado a {{member}}", + "assigned": "Asignado", + "document": "Documento", + "documentEditor": "Editor del documento" + }, "common": { "export": "Exportar", "pdf": "PDF", diff --git a/src/locales/hu.json b/src/locales/hu.json index 0e6cd0147..404380320 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1,6 +1,7 @@ { "nav": { "home": "Kezdőlap", + "workspaces": "Munkaterek", "generate": "Generálás", "attack": "Támadás", "brainstorm": "Ötletelés", @@ -26,6 +27,7 @@ }, "navDesc": { "home": "Mód választása", + "workspaces": "Közös esetírás", "generate": "Érvek építése", "attack": "Gyengeségek keresése", "brainstorm": "Ötletek felfedezése", @@ -38,6 +40,32 @@ "flowchart": "Érvek térképezése", "history": "Mentett elemek" }, + "workspaces": { + "title": "Munkaterek", + "new": "Új munkatér", + "motion": "Tételmondat", + "motionPlaceholder": "Az iskoláknak be kellene tiltaniuk a mobiltelefonokat", + "create": "Munkatér létrehozása", + "creating": "Létrehozás...", + "createFailed": "A munkatér létrehozása sikertelen. Próbáld újra.", + "yours": "Saját munkaterek", + "loading": "Munkaterek betöltése...", + "loadFailed": "A munkaterek nem tölthetők be.", + "empty": "Hozd létre fent az első munkateredet.", + "active": "Aktív", + "archived": "Archivált", + "loadingOne": "Munkatér megnyitása...", + "unavailable": "A munkatér nem érhető el.", + "editorTitle": "Vitamunkatér", + "side": "Oldal", + "proSide": "Mellette", + "conSide": "Ellene", + "files": "Fájlok", + "assignedTo": "Felelős: {{member}}", + "assigned": "Kiosztva", + "document": "Dokumentum", + "documentEditor": "Dokumentumszerkesztő" + }, "common": { "export": "Exportálás", "pdf": "PDF", diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx new file mode 100644 index 000000000..2cfb55223 --- /dev/null +++ b/src/pages/WorkspacePage.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { getWorkspaceBundle } from "@/lib/workspace/client"; + +export default function WorkspacePage() { + const { t } = useTranslation(); + const { workspaceId = "" } = useParams(); + const [side, setSide] = useState<"pro" | "con">("pro"); + const [selectedDocumentId, setSelectedDocumentId] = useState(null); + const workspace = useQuery({ + queryKey: ["workspace", workspaceId], + queryFn: () => getWorkspaceBundle(workspaceId), + enabled: Boolean(workspaceId), + }); + const documents = useMemo( + () => workspace.data?.documents.filter((document) => document.side === side && document.state === "active") ?? [], + [side, workspace.data?.documents], + ); + const selectedDocument = documents.find((document) => document.id === selectedDocumentId) ?? documents[0]; + + useEffect(() => { + setSelectedDocumentId(documents[0]?.id ?? null); + }, [documents]); + + if (workspace.isLoading) { + return

{t("workspaces.loadingOne", "Opening workspace...")}

; + } + if (workspace.isError || !workspace.data) { + return

{t("workspaces.unavailable", "Workspace unavailable.")}

; + } + + return ( +
+
+

{workspace.data.workspace.motion}

+

+ {workspace.data.workspace.title ?? t("workspaces.editorTitle", "Debate workspace")} +

+ +
+ + +
+ +
+ + +
+
+

{selectedDocument?.title}

+ {selectedDocument?.assignee_id && {t("workspaces.assigned", "Assigned")}} +
+
+
+
+
+
+ ); +} diff --git a/src/pages/WorkspacesPage.tsx b/src/pages/WorkspacesPage.tsx new file mode 100644 index 000000000..5cad2b510 --- /dev/null +++ b/src/pages/WorkspacesPage.tsx @@ -0,0 +1,133 @@ +import { useRef, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { supabase } from "@/lib/supabase/client"; +import { listWorkspaces } from "@/lib/workspace/client"; + +export default function WorkspacesPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const [motion, setMotion] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const requestId = useRef(null); + const workspaces = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces }); + + const createWorkspace = async (event: FormEvent) => { + event.preventDefault(); + const normalizedMotion = motion.trim(); + if (!normalizedMotion || creating) return; + + setCreating(true); + setError(null); + requestId.current ??= crypto.randomUUID(); + const language = ["en", "es", "hu"].includes(i18n.language) + ? i18n.language + : "en"; + const { data, error: createError } = await supabase.rpc("create_workspace", { + p_language: language, + p_motion: normalizedMotion, + p_request_id: requestId.current, + p_title: null, + }); + + if (createError || typeof data !== "string") { + setError(t("workspaces.createFailed", "Workspace creation failed. Try again.")); + setCreating(false); + return; + } + + requestId.current = null; + navigate(`/app/workspaces/${data}`); + }; + + return ( +
+
+

+ {t("workspaces.title", "Workspaces")} +

+ + + {t("workspaces.new", "New workspace")} + + +
+
+ +