diff --git a/src/components/shell/ProjectSwitcher.tsx b/src/components/shell/ProjectSwitcher.tsx index 3aaa723..1656f41 100644 --- a/src/components/shell/ProjectSwitcher.tsx +++ b/src/components/shell/ProjectSwitcher.tsx @@ -1,45 +1,131 @@ "use client"; import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, + Input, + Label, } from "@mind-studio/ui"; +import { useState } from "react"; import { useShell } from "@/lib/shell/context"; /** * The top-left project switcher (wireframe "▼ Product"). Lists the projects in * the current workspace plus an "All / no project" option that scopes the shell - * to the whole workspace. Selecting a project calls `setProject`. + * to the whole workspace. Selecting a project calls `setProject`; "New project" + * writes a project container in the active workspace and switches to it. */ export function ProjectSwitcher() { - const { projects, project, setProject } = useShell(); + const { projects, project, setProject, workspacePod } = useShell(); const current = project?.name ?? "No project"; + const [creating, setCreating] = useState(false); return ( - - - - - - Projects in this workspace - setProject(null)}> - All · no project - - {projects.length > 0 && } - {projects.map((p) => ( - setProject(p)}> - {p.name} + <> + + + + + + Projects in this workspace + setProject(null)}> + All · no project - ))} - {projects.length === 0 && No projects yet} - - + {projects.length > 0 && } + {projects.map((p) => ( + setProject(p)}> + {p.name} + + ))} + {projects.length === 0 && No projects yet} + {/* Creating needs an active workspace pod to write into. */} + {workspacePod && ( + <> + + setCreating(true)}> + + New project + + + )} + + + + + ); +} + +function NewProjectDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { createProject } = useShell(); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim() || busy) return; + setBusy(true); + setError(null); + try { + await createProject({ name }); + setName(""); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Couldn't create that project."); + } finally { + setBusy(false); + } + } + + return ( + !busy && onOpenChange(v)}> + + + New project + + A project is a board, timeline, meetings and briefings — stored in your workspace pod. + + +
+ + setName(e.target.value)} + placeholder="Marketing site relaunch" + /> + {error &&

{error}

} + + + +
+
+
); } diff --git a/src/lib/shell/context.tsx b/src/lib/shell/context.tsx index ae61232..4f862db 100644 --- a/src/lib/shell/context.tsx +++ b/src/lib/shell/context.tsx @@ -671,6 +671,72 @@ export function ShellProvider({ children }: { children: React.ReactNode }) { const setProject = useCallback((p: Project | null) => setProjectState(p), []); + // Create a project container in the CURRENT workspace pod and switch to it. + // Unlike createWorkspace (which provisions a whole pod), a project is just a + // `{podRoot}projects/{id}/` container the signed-in user already owns — one + // privileged PUT of project.ttl. We write the Projects-app vocab (ws:Project + + // an Owner ws:Membership) so the embedded Projects app reads it as owned; the + // bridge can't do this itself (its writes are scope-checked to the app zone), + // which is why creation lives in the shell. After writing we re-enumerate and + // setProject() — IframeHost re-brokers the new project into the frame, no reload. + const createProject = useCallback( + async ({ name }: { name: string }) => { + const pod = workspacePod; + if (!pod || !webId) throw new Error("You need an active workspace to create a project."); + const title = name.trim(); + if (!title) throw new Error("Give the project a name."); + const slug = + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "project"; + const rand = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID().slice(0, 8) + : Math.random().toString(36).slice(2, 10); + const id = `${slug}-${rand}`; + const root = ensureSlash(pod); + const url = `${root}projects/${id}/`; + const start = new Date(); + const end = new Date(start); + end.setUTCFullYear(end.getUTCFullYear() + 1); + const ownerName = + account?.displayName ?? + webId + .replace(/\/profile\/card#me$/, "") + .split("/") + .filter(Boolean) + .pop() ?? + "Owner"; + const esc = (s: string) => s.replace(/"/g, '\\"'); + const ttl = `@prefix dct: . +@prefix ws: . + +<#project> a ws:Project ; + dct:identifier "${esc(id)}" ; + dct:title "${esc(title)}" ; + ws:status "active" ; + ws:startDate "${start.toISOString().slice(0, 10)}" ; + ws:endDate "${end.toISOString().slice(0, 10)}" . + +<#m-owner> a ws:Membership ; + ws:agent <${webId}> ; + ws:role ws:Owner ; + ws:name "${esc(ownerName)}" . +`; + const fetchFn = (await getPlatform()).pod.fetch; + const r = await fetchFn(`${url}project.ttl`, { + method: "PUT", + headers: { "Content-Type": "text/turtle" }, + body: ttl, + }); + if (!r.ok && r.status !== 205) throw new Error(`Could not create the project (${r.status}).`); + await loadActiveWorkspace(root); + setProject({ id, url, name: title }); + }, + [webId, workspacePod, account, loadActiveWorkspace, setProject], + ); + // C4: re-resolve identity after a passport switch. Drop the workspace override // (the previous identity's active pod isn't ours anymore) and re-run refresh, // which reads the now-active WebID from the platform (the passport) and its pod. @@ -704,6 +770,7 @@ export function ShellProvider({ children }: { children: React.ReactNode }) { addWorkspace, createWorkspace, setProject, + createProject, refresh, reloadIdentity, signOut, diff --git a/src/lib/shell/types.ts b/src/lib/shell/types.ts index 0913f9a..3952b8a 100644 --- a/src/lib/shell/types.ts +++ b/src/lib/shell/types.ts @@ -188,6 +188,15 @@ export interface ShellContextValue { */ createWorkspace(opts: { name: string; server?: string; email?: string }): Promise; setProject(project: Project | null): void; + /** + * Create a project container ({podRoot}projects/{id}/) in the CURRENT + * workspace and switch to it. The user types only a name; project.ttl is + * written in the Projects-app vocab (ws:Project + an Owner ws:Membership) so + * the embedded Projects app reads it as owned. This is a shell-side privileged + * write — the bridge scope-checks app writes to their own zone, so an embedded + * app can't create a sibling project itself (it asks the shell via this UI). + */ + createProject(opts: { name: string }): Promise; /** Re-read workspace/project/app context from the pod. */ refresh(): Promise; /**