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 (
+
);
}
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;
/**