Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 108 additions & 22 deletions src/components/shell/ProjectSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-foreground outline-none transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary">
<span className="text-muted-foreground">▼</span>
<span className="max-w-40 truncate">{current}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Projects in this workspace</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setProject(null)}>
All <span className="ml-1 text-muted-foreground">· no project</span>
</DropdownMenuItem>
{projects.length > 0 && <DropdownMenuSeparator />}
{projects.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => setProject(p)}>
<span className="truncate">{p.name}</span>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-foreground outline-none transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
>
<span className="text-muted-foreground">▼</span>
<span className="max-w-40 truncate">{current}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Projects in this workspace</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setProject(null)}>
All <span className="ml-1 text-muted-foreground">· no project</span>
</DropdownMenuItem>
))}
{projects.length === 0 && <DropdownMenuItem disabled>No projects yet</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
{projects.length > 0 && <DropdownMenuSeparator />}
{projects.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => setProject(p)}>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{projects.length === 0 && <DropdownMenuItem disabled>No projects yet</DropdownMenuItem>}
{/* Creating needs an active workspace pod to write into. */}
{workspacePod && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setCreating(true)}>
<span className="text-muted-foreground">+</span>
<span className="ml-1">New project</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<NewProjectDialog open={creating} onOpenChange={setCreating} />
</>
);
}

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<string | null>(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 (
<Dialog open={open} onOpenChange={(v) => !busy && onOpenChange(v)}>
<DialogContent>
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogDescription>
A project is a board, timeline, meetings and briefings — stored in your workspace pod.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="mt-4 space-y-2">
<Label htmlFor="project-name">Project name</Label>
<Input
id="project-name"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Marketing site relaunch"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter className="mt-6">
<Button type="submit" disabled={!name.trim() || busy}>
{busy ? "Creating…" : "Create project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
67 changes: 67 additions & 0 deletions src/lib/shell/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <http://purl.org/dc/terms/> .
@prefix ws: <https://mind.dev/ns/workspace#> .

<#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.
Expand Down Expand Up @@ -704,6 +770,7 @@ export function ShellProvider({ children }: { children: React.ReactNode }) {
addWorkspace,
createWorkspace,
setProject,
createProject,
refresh,
reloadIdentity,
signOut,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/shell/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ export interface ShellContextValue {
*/
createWorkspace(opts: { name: string; server?: string; email?: string }): Promise<void>;
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<void>;
/** Re-read workspace/project/app context from the pod. */
refresh(): Promise<void>;
/**
Expand Down
Loading