feat(hub): rename, describe and pick an icon for a project, from its own Settings page (#51)

Project gains two optional fields — Description (<=280 chars) and Icon (a
lucide icon name) — and PATCH /api/projects/{id} becomes a real partial
update: every field is a *string, so only the keys present in the body
change, and {"description":""} clears where an omitted key leaves alone.
Validation returns 400 for an empty or >120-char name, a sibling-name
collision, a >280-char description, and an icon failing ^[a-z0-9-]{1,32}$.
The permission gate is deliberately untouched.

Storage: the file backend marshals Project whole, so it rides along; the SQL
backend needs the two columns added to an already-created table, which
CREATE TABLE IF NOT EXISTS can't do — hence addColumns(), an idempotent
ALTER helper (same shape BEA-2 introduces for creator/default_level, so the
two merge into one map).

Frontend: Settings is now shadcn sectioned cards (General / About / Danger
zone — adds card, separator, textarea to components/ui), with an RHF+zod
form that PATCHes only its dirty keys and refreshes the hub queries, so the
nav mark and dashboard header update without a reload. Icons come from a
curated ~30-icon lucide shortlist (named imports, so Vite still tree-shakes
the rest); an unknown or empty name renders the folder placeholder. The
glyph shows in the project mark on the switcher trigger and every menu row,
and beside the name on the dashboard header with the description under it.
The org admin panel loses its per-project Rename button, which collapses its
two project lists into one read-only list for everybody.

One fix found while driving the real UI: Tailwind preflight is off in this
app, so copied shadcn form controls rendered monospace/black and cards drew
a near-white hairline. Both are now supplied by slot in style.css.
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-27 09:56:59 +09:00
committed by GitHub
parent 93476700dc
commit 65491f9576
25 changed files with 1015 additions and 267 deletions
+2 -1
View File
@@ -120,10 +120,11 @@ classDiagram
class ProjectDB {
-repo ProjectRepo
-byID
+Get +Create +Rename +List
+Get +Create +Update +Rename +List
}
class Project {
+ID +Name +Org +Created
+Description +Icon
}
class ShareDB {
+9 -4
View File
@@ -24,8 +24,11 @@ func (s *Server) projectOwner(r *http.Request, projectID string) bool {
return s.Dir.Role(org, s.requestUser(r).Email) == RoleOwner
}
// handleProjectRename renames a project. Owner of its org only.
func (s *Server) handleProjectRename(w http.ResponseWriter, r *http.Request) {
// handleProjectUpdate edits a project's name, description and icon. Owner of
// its org only. It's a partial update: every field is a pointer, so only the
// keys actually present in the body change — {"description":""} clears the
// description, omitting the key leaves it alone.
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
@@ -40,13 +43,15 @@ func (s *Server) handleProjectRename(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Name string `json:"name"`
Name *string `json:"name"`
Description *string `json:"description"`
Icon *string `json:"icon"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if err := s.Projects.Rename(id, req.Name); err != nil {
if err := s.Projects.Update(id, req.Name, req.Description, req.Icon); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
+10
View File
@@ -129,6 +129,10 @@ func TestMetaStoreConformance(t *testing.T) {
if err := projects.Rename(p1.ID, "handbook"); err != nil {
t.Fatal(err)
}
desc, icon := "everything support needs", "book-open"
if err := projects.Update(p1.ID, nil, &desc, &icon); err != nil {
t.Fatal(err)
}
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
if err := projects.Delete(p2.ID); err != nil {
t.Fatal(err)
@@ -221,6 +225,12 @@ func TestMetaStoreConformance(t *testing.T) {
if !ok || hb.Name != "handbook" {
t.Fatalf("rename lost across reload: %+v", hb)
}
// Description/icon are the columns migrate() has to ADD to an
// already-created projects table; the reopen above already ran
// migrate() a second time, so surviving here proves it's a no-op.
if hb.Description != "everything support needs" || hb.Icon != "book-open" {
t.Fatalf("description/icon lost across reload: %+v", hb)
}
orgs2, _ := NewOrgDB(st2.Orgs())
ro, ok := orgs2.Get(org.ID)
+40 -5
View File
@@ -167,6 +167,40 @@ func (s *sqlMetaStore) migrate() error {
return fmt.Errorf("migrate: %w", err)
}
}
// Columns added after the tables shipped. CREATE TABLE IF NOT EXISTS does
// nothing for an existing table, so these need a real (idempotent) ALTER.
return s.addColumns("projects", map[string]string{
"description": `TEXT NOT NULL DEFAULT ''`,
"icon": `TEXT NOT NULL DEFAULT ''`,
})
}
// addColumns adds any of cols that the table doesn't already have. The live
// column set comes from an empty result set's metadata, which both drivers
// (modernc/sqlite and pgx) report the same way — no engine-specific catalog
// query, and safe to run on every start.
func (s *sqlMetaStore) addColumns(table string, cols map[string]string) error {
rows, err := s.db.Query(`SELECT * FROM ` + table + ` LIMIT 0`)
if err != nil {
return fmt.Errorf("migrate %s: %w", table, err)
}
names, err := rows.Columns()
rows.Close()
if err != nil {
return fmt.Errorf("migrate %s: %w", table, err)
}
have := make(map[string]bool, len(names))
for _, n := range names {
have[strings.ToLower(n)] = true
}
for col, spec := range cols {
if have[col] {
continue
}
if _, err := s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + col + ` ` + spec); err != nil {
return fmt.Errorf("migrate %s.%s: %w", table, col, err)
}
}
return nil
}
@@ -259,7 +293,7 @@ func (r *sqlAccountRepo) PutPolicy(p authPolicy) error {
type sqlProjectRepo struct{ s *sqlMetaStore }
func (r *sqlProjectRepo) Load() ([]Project, error) {
rows, err := r.s.db.Query(`SELECT id, name, org, created FROM projects`)
rows, err := r.s.db.Query(`SELECT id, name, org, created, description, icon FROM projects`)
if err != nil {
return nil, err
}
@@ -268,7 +302,7 @@ func (r *sqlProjectRepo) Load() ([]Project, error) {
for rows.Next() {
var p Project
var created string
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created); err != nil {
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created, &p.Description, &p.Icon); err != nil {
return nil, err
}
p.Created = tdec(created)
@@ -278,9 +312,10 @@ func (r *sqlProjectRepo) Load() ([]Project, error) {
}
func (r *sqlProjectRepo) Put(p Project) error {
return r.s.exec(`INSERT INTO projects (id,name,org,created) VALUES (?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created`,
p.ID, p.Name, p.Org, tenc(p.Created))
return r.s.exec(`INSERT INTO projects (id,name,org,created,description,icon) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon)
}
func (r *sqlProjectRepo) Delete(id string) error {
+16 -9
View File
@@ -92,20 +92,27 @@ test("org admin: public share audit lists and revokes", async ({ page }) => {
await expect(page.locator(".admin-item", { hasText: "index.md" })).toHaveCount(0);
});
test("org admin: project rename (delete lives on project settings)", async ({ page }) => {
test("org admin: the project list is read-only; rename lives on project settings", async ({
page,
}) => {
await login(page);
const made = await (await page.request.post("/api/projects", { data: { name: "doomed" } })).json();
await page.reload(); // pick up the new project
await openOrgSettings(page);
const row = page.locator(".admin-item", { hasText: "doomed" });
await row.locator(".ai-btn", { hasText: "Rename" }).click();
await page.fill(".modal-input", "doomed-2");
await page.click(".modal .pbtn");
await expectToast(page, "Renamed");
const row2 = page.locator(".admin-item", { hasText: "doomed-2" });
await expect(row2).toBeVisible();
// The one-click delete is gone: Settings' type-the-name flow is the only way.
await expect(row2.locator(".ai-del")).toHaveCount(0);
await expect(row).toBeVisible();
// Neither affordance lives here any more: renaming and deleting a project
// both happen on the project's own Settings page.
await expect(row.locator(".ai-btn", { hasText: "Rename" })).toHaveCount(0);
await expect(row.locator(".ai-del")).toHaveCount(0);
// …and renaming there works, showing up in the nav.
await page.goto(`/${made.project.id}/settings`);
await page.fill("#ps-name", "doomed-2");
await page.click("#ps-save");
await expectToast(page, "Saved");
await expect(page.locator("#projects .proj-trigger")).toContainText("doomed-2");
await page.request.delete("/api/projects/" + made.project.id); // clean up
});
+57 -1
View File
@@ -209,12 +209,68 @@ test("project settings: danger zone is owner-only", async ({ page }) => {
await expect(page.locator(".ps-danger .danger-btn")).toHaveText("Delete project");
});
test("project settings: a member sees no danger zone", async ({ page }) => {
test("project settings: a member sees no danger zone and cannot edit", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await expect(page.locator(".project-settings h2")).toHaveText("wiki"); // page rendered
await expect(page.locator(".ps-danger")).toHaveCount(0);
// The General card is shown, disabled — not hidden, and with no way to submit.
await expect(page.locator("#ps-name")).toBeDisabled();
await expect(page.locator("#ps-desc")).toBeDisabled();
await expect(page.locator("#ps-icon-btn")).toBeDisabled();
await expect(page.locator("#ps-save")).toHaveCount(0);
});
test("project settings: icon + description save, and show in nav and dashboard", async ({
page,
}) => {
await login(page);
const made = await (await page.request.post("/api/projects", { data: { name: "dressed" } })).json();
const pid = made.project.id;
await page.goto(`/${pid}/settings`);
// Nothing dirty yet → nothing to save.
await expect(page.locator("#ps-save")).toBeDisabled();
// Placeholder until an icon is picked.
await expect(page.locator(".ps-icon-row .proj-mark svg")).toHaveCount(1);
await page.click("#ps-icon-btn");
await page.click('.ps-icon-grid [aria-label="book-open"]');
await page.fill("#ps-desc", "everything support needs");
await expect(page.locator(".ps-count")).toHaveText("24 / 280");
await expect(page.locator("#ps-save")).toBeEnabled();
await page.click("#ps-save");
await expectToast(page, "Saved");
await expect(page.locator("#ps-save")).toBeDisabled(); // clean again
// Both surfaces pick it up without a reload: the nav mark right here, and
// the project header on the next SPA navigation (same header component the
// project home renders).
await expect(page.locator("#projects .proj-trigger .proj-mark svg")).toHaveCount(1);
await page.click("#nav-install");
await expect(page.locator(".in-desc")).toHaveText("everything support needs");
await expect(page.locator(".gd-head .proj-mark svg")).toHaveCount(1);
// …and it survives a reload, i.e. it really was persisted — on the project
// home header, and back in the form.
await page.goto(`/${pid}`);
await expect(page.locator(".in-desc")).toHaveText("everything support needs");
await expect(page.locator(".gd-head .proj-mark svg")).toHaveCount(1);
await page.goto(`/${pid}/settings`);
await expect(page.locator("#ps-desc")).toHaveValue("everything support needs");
await page.request.delete("/api/projects/" + pid); // clean up
});
test("project settings: an over-long description is refused inline", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await page.fill("#ps-desc", "x".repeat(281));
await page.click("#ps-save");
await expect(page.locator("#ps-desc-err")).toBeVisible();
await expect(page.locator("#ps-desc")).toHaveValue("x".repeat(281)); // form not cleared
});
test("project settings: delete needs the exact name typed, then navigates away", async ({ page }) => {
@@ -25,6 +25,9 @@ export interface Project {
name: string;
org?: string;
created?: string;
description?: string;
/** lucide icon name (kebab-case); unknown or absent → the folder placeholder */
icon?: string;
}
export interface ProjectList {
@@ -165,7 +165,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
org={routeOrg}
projects={projects}
myEmail={config.me?.email || ""}
onProjectsChanged={refresh}
/>
),
}
@@ -1,6 +1,8 @@
import { useState } from "react";
import type { Project } from "../api/types";
import { copyText } from "../util";
import { ProjectIcon } from "./shell";
import { projColor } from "./ProjectNav";
/* ---- project home guide ----
How to mount the project as a local folder and connect a coding agent,
@@ -149,7 +151,17 @@ export function ConnectGuide({ project }: { project: Project }) {
return (
<div className="guide">
<h1 className="in-title">{project.name}</h1>
<h1 className="in-title gd-head">
<span
className="proj-mark"
aria-hidden="true"
style={{ background: projColor(project.name) }}
>
<ProjectIcon name={project.icon} />
</span>
{project.name}
</h1>
{project.description && <p className="in-desc">{project.description}</p>}
<p className="dl-sub">
Mount this project as a folder on any machine and connect your coding agent: files sync both
ways in the background, every change is journaled with who made it, and agent reads feed
@@ -13,7 +13,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api, getJSON, postJSON } from "../api/http";
import type { Org, OrgInviteInfo, OrgShareInfo, Project } from "../api/types";
import { modalConfirm, modalPrompt } from "../modal";
import { modalConfirm } from "../modal";
import { toast } from "../toast";
import { copyText } from "../util";
import { Button } from "@/components/ui/button";
@@ -59,12 +59,10 @@ export function OrgAdmin({
org,
projects,
myEmail,
onProjectsChanged,
}: {
org: Org;
projects: Project[];
myEmail: string;
onProjectsChanged: () => Promise<void>;
}) {
const qc = useQueryClient();
const owner = org.role === "owner";
@@ -145,51 +143,20 @@ export function OrgAdmin({
<h3>Members</h3>
<MembersTable org={org} owner={owner} myEmail={myEmail} onChanged={refreshOrgs} />
{!owner && (
<>
<h3>Projects</h3>
<div className="admin-list">
{orgProjects.length === 0 && <div className="admin-empty">No projects yet.</div>}
{orgProjects.map((p) => (
<div className="admin-item" key={p.id}>
<span className="ai-main" title={p.name}>{p.name}</span>
</div>
))}
{/* Read-only for everybody: a project is renamed (and deleted) from its
own Settings page, so this list is the same whatever your role. */}
<h3>Projects</h3>
<div className="admin-list">
{orgProjects.length === 0 && <div className="admin-empty">No projects yet.</div>}
{orgProjects.map((p) => (
<div className="admin-item" key={p.id}>
<span className="ai-main" title={p.name}>{p.name}</span>
</div>
</>
)}
))}
</div>
{owner && (
<>
<h3>Projects</h3>
<div className="admin-list">
{orgProjects.length === 0 && <div className="admin-empty">No projects yet.</div>}
{orgProjects.map((p) => (
<div className="admin-item" key={p.id}>
<span className="ai-main" title={p.name}>{p.name}</span>
<Button
variant="subtle"
aria-label={`Rename ${p.name}`}
onClick={async () => {
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
if (name === null || name.trim() === p.name) return;
try {
await api("PATCH", "/api/projects/" + p.id, { name });
toast("Renamed.");
await onProjectsChanged();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Rename
</Button>
{/* Delete lives on the project's own Settings page, behind
type-the-name — one way to delete, and it's the hard one. */}
</div>
))}
</div>
<div className="admin-h">
<h3>Invite links</h3>
<Button
@@ -1,5 +1,5 @@
import { navigate } from "../nav";
import { Icon } from "./shell";
import { Icon, ProjectIcon } from "./shell";
import {
Select,
SelectContent,
@@ -41,6 +41,7 @@ export function ProjectNav({
menu?: ProjectMenu;
}) {
const refresh = useHubRefresh();
const current = projects.find((p) => p.id === currentId);
const create = async () => {
const name = await modalPrompt("New project", "Project name", "", "Create");
@@ -75,22 +76,39 @@ export function ProjectNav({
>
<SelectTrigger
id="project-select"
aria-label={`Switch project — current: ${projects.find((p) => p.id === currentId)?.name ?? "none"}`}
title={projects.find((p) => p.id === currentId)?.name}
aria-label={`Switch project — current: ${current?.name ?? "none"}`}
title={current?.name}
className="proj-trigger"
>
{currentId && (
{current && (
<span
className="proj-mark"
aria-hidden="true"
style={{ background: projColor(projects.find((p) => p.id === currentId)?.name || "") }}
/>
style={{ background: projColor(current.name) }}
>
<ProjectIcon name={current.icon} />
</span>
)}
{/* SelectValue mirrors the selected item's text into the trigger —
which, now that every item carries its own mark, would draw a
second one here. Render the name ourselves when we have it,
keeping the [data-slot="select-value"] styling hook. */}
{current ? (
<span data-slot="select-value">{current.name}</span>
) : (
<SelectValue placeholder="Select a project" />
)}
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent className="proj-menu" position="popper" sideOffset={4}>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span
className="proj-mark"
aria-hidden="true"
style={{ background: projColor(p.name) }}
>
<ProjectIcon name={p.icon} />
</span>
{p.name}
</SelectItem>
))}
@@ -1,12 +1,45 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api } from "../api/http";
import { modalPrompt } from "../modal";
import { toast } from "../toast";
import { useHubRefresh } from "../hooks/useHub";
import { PROJECT_ICONS, ProjectIcon } from "./shell";
import { projColor } from "./ProjectNav";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import type { Org, Project } from "../api/types";
// Settings for the open project (sidebar menu). Today: identity facts and
// the delete danger zone; per-project knobs land here as they grow.
// Install/connect lives on the Installation page.
// Settings for the open project (sidebar menu): General edits the name,
// description and icon; About holds the identity facts; the danger zone
// deletes. Install/connect lives on the Installation page.
const MAX_DESC = 280;
// Mirrors the server's rules (projects.go) so a typo never round-trips.
const schema = z.object({
name: z
.string()
.trim()
.min(1, "Give the project a name.")
.max(120, "Keep the name under 120 characters."),
description: z.string().max(MAX_DESC, `Keep the description under ${MAX_DESC} characters.`),
icon: z.string(),
});
type Values = z.infer<typeof schema>;
export function ProjectSettings({
project,
org,
@@ -16,59 +49,232 @@ export function ProjectSettings({
org: Org | null;
onDeleted: () => Promise<void>;
}) {
const refresh = useHubRefresh();
// Owner-only, and only as UX: handleProjectUpdate enforces it too. Swap for
// the project-level permission once BEA-2 lands.
const mayEdit = org?.role === "owner";
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
name: project.name,
description: project.description ?? "",
icon: project.icon ?? "",
},
});
// Switching projects (or a refresh bringing new values) re-seeds the form,
// so the fields never show another project's metadata.
useEffect(() => {
form.reset({
name: project.name,
description: project.description ?? "",
icon: project.icon ?? "",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id, project.name, project.description, project.icon]);
const icon = form.watch("icon");
const description = form.watch("description");
const save = form.handleSubmit(async (values) => {
// Only the dirty keys travel: PATCH is a partial update, so an untouched
// field is never sent — and never overwritten by a stale value.
const dirty = form.formState.dirtyFields;
const body: Partial<Values> = {};
if (dirty.name) body.name = values.name.trim();
if (dirty.description) body.description = values.description;
if (dirty.icon) body.icon = values.icon;
if (Object.keys(body).length === 0) return;
try {
await api("PATCH", "/api/projects/" + project.id, body);
toast("Saved.");
form.reset({ ...values, name: values.name.trim() }); // clean, keeps what was typed
await refresh(); // nav mark + dashboard header update without a reload
} catch (e) {
toast((e as Error).message, true); // form left alone, so nothing is lost
}
});
return (
<div className="project-settings">
<h2>{project.name}</h2>
<dl className="ps-facts">
<dt>Project id</dt>
<dd>
<code>{project.id}</code>
</dd>
{org && (
<>
<dt>Workspace</dt>
<dd>{org.name}</dd>
</>
)}
{project.created && (
<>
<dt>Created</dt>
<dd>{new Date(project.created).toLocaleDateString()}</dd>
</>
)}
</dl>
<Card>
<CardHeader>
<CardTitle>General</CardTitle>
<CardDescription>Name, description and icon for this project.</CardDescription>
</CardHeader>
<Separator />
<CardContent>
<form className="ps-form" onSubmit={save}>
<div className="ps-field">
<Label htmlFor="ps-icon-btn">Icon</Label>
<div className="ps-icon-row">
<span
className="proj-mark"
aria-hidden="true"
style={{ background: projColor(project.name) }}
>
<ProjectIcon name={icon} />
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button id="ps-icon-btn" type="button" variant="subtle" disabled={!mayEdit}>
Change
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="ps-icon-grid">
{/* Real menu items, so the grid closes on pick and works
from the keyboard like every other menu in the app. */}
<DropdownMenuItem
className={"ps-icon-cell" + (icon === "" ? " active" : "")}
title="Default"
aria-label="Default icon"
onSelect={() => form.setValue("icon", "", { shouldDirty: true })}
>
<ProjectIcon />
</DropdownMenuItem>
{Object.keys(PROJECT_ICONS).map((name) => (
<DropdownMenuItem
key={name}
className={"ps-icon-cell" + (icon === name ? " active" : "")}
title={name}
aria-label={name}
onSelect={() => form.setValue("icon", name, { shouldDirty: true })}
>
<ProjectIcon name={name} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="ps-field">
<Label htmlFor="ps-name">Name</Label>
<Input
id="ps-name"
disabled={!mayEdit}
aria-invalid={!!form.formState.errors.name}
aria-describedby={form.formState.errors.name ? "ps-name-err" : undefined}
{...form.register("name")}
/>
{form.formState.errors.name && (
<span id="ps-name-err" role="alert" className="field-err">
{form.formState.errors.name.message}
</span>
)}
</div>
<div className="ps-field">
<Label htmlFor="ps-desc">
Description <span className="ps-opt">(optional)</span>
</Label>
<Textarea
id="ps-desc"
rows={2}
disabled={!mayEdit}
placeholder="What this project is for."
aria-invalid={!!form.formState.errors.description}
aria-describedby={form.formState.errors.description ? "ps-desc-err" : undefined}
{...form.register("description")}
/>
<div className="ps-meta">
{form.formState.errors.description ? (
<span id="ps-desc-err" role="alert" className="field-err">
{form.formState.errors.description.message}
</span>
) : (
<span />
)}
<span className="ps-count">
{description.length} / {MAX_DESC}
</span>
</div>
</div>
{mayEdit && (
<>
<Separator />
<div className="ps-actions">
<Button
id="ps-save"
type="submit"
variant="primary"
disabled={!form.formState.isDirty || form.formState.isSubmitting}
>
Save changes
</Button>
</div>
</>
)}
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
</CardHeader>
<Separator />
<CardContent>
<dl className="ps-facts">
<dt>Project id</dt>
<dd>
<code>{project.id}</code>
</dd>
{org && (
<>
<dt>Workspace</dt>
<dd>{org.name}</dd>
</>
)}
{project.created && (
<>
<dt>Created</dt>
<dd>{new Date(project.created).toLocaleDateString()}</dd>
</>
)}
</dl>
</CardContent>
</Card>
{/* Owner-only, and only as UX: handleProjectDelete enforces it too. */}
{org?.role === "owner" && (
<section className="ps-danger">
<h3>Danger zone</h3>
<p>
Deleting removes the project from this hub. Its files stay in storage.
This can't be undone.
</p>
<Button
variant="danger"
onClick={async () => {
const typed = await modalPrompt(
`Delete “${project.name}”?`,
"This can't be undone. Type the project name to confirm:",
"",
"Delete project",
{ match: project.name, danger: true },
);
if (typed === null) return;
try {
await api("DELETE", "/api/projects/" + project.id);
toast(`Deleted “${project.name}”.`);
await onDeleted();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Delete project
</Button>
</section>
<Card className="ps-danger">
<CardHeader>
<CardTitle>Danger zone</CardTitle>
</CardHeader>
<Separator />
<CardContent>
<p>
Deleting removes the project from this hub. Its files stay in storage. This can't be
undone.
</p>
<Button
variant="danger"
onClick={async () => {
const typed = await modalPrompt(
`Delete “${project.name}”?`,
"This can't be undone. Type the project name to confirm:",
"",
"Delete project",
{ match: project.name, danger: true },
);
if (typed === null) return;
try {
await api("DELETE", "/api/projects/" + project.id);
toast(`Deleted “${project.name}”.`);
await onDeleted();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Delete project
</Button>
</CardContent>
</Card>
)}
</div>
);
@@ -2,32 +2,54 @@ import type { ReactNode } from "react";
import { requestSearch } from "../search";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Beaker,
BookOpen,
Briefcase,
Bug,
Calendar,
Check,
ChevronDown,
ChevronRight,
Clock,
Code,
Compass,
Copy,
Database,
Download,
Ellipsis,
FileText,
Flag,
Folder,
Gauge,
Heart,
Image,
LayoutDashboard,
Lightbulb,
Gavel,
Globe,
GraduationCap,
History,
Link,
Lock,
LogOut,
Megaphone,
Menu,
Music,
Package,
PenLine,
Plus,
Rocket,
Search,
Settings,
Share2,
Shield,
SquareTerminal,
Star,
Trash2,
TriangleAlert,
Upload,
Users,
Wrench,
X,
type LucideIcon,
} from "lucide-react";
@@ -136,6 +158,53 @@ export function Icon({ name }: { name: string }) {
return C ? <C className="ico" aria-hidden="true" /> : null;
}
// The icons a project may choose from, keyed by their real lucide name (so
// what lands in the database is a public, portable identifier — not one of
// ICONS' historical sprite aliases). Deliberately a curated shortlist: these
// are named imports, which is what lets Vite tree-shake the other ~1500
// lucide icons out of the bundle. Adding one here is the whole change; the
// server only ever validates the shape of the string.
export const PROJECT_ICONS: Record<string, LucideIcon> = {
folder: Folder,
"book-open": BookOpen,
"file-text": FileText,
"pen-line": PenLine,
users: Users,
briefcase: Briefcase,
megaphone: Megaphone,
rocket: Rocket,
lightbulb: Lightbulb,
flag: Flag,
star: Star,
heart: Heart,
code: Code,
"square-terminal": SquareTerminal,
bug: Bug,
wrench: Wrench,
database: Database,
package: Package,
beaker: Beaker,
gauge: Gauge,
shield: Shield,
lock: Lock,
gavel: Gavel,
globe: Globe,
compass: Compass,
calendar: Calendar,
clock: Clock,
"graduation-cap": GraduationCap,
image: Image,
music: Music,
};
// ProjectIcon renders a project's chosen glyph. One fallback, one place:
// no icon set, or a name this build doesn't know (hand-written into storage,
// or dropped from the list later) → the folder placeholder.
export function ProjectIcon({ name, className }: { name?: string; className?: string }) {
const C = PROJECT_ICONS[name ?? ""] ?? Folder;
return <C className={className} aria-hidden="true" />;
}
/* The BearDrive mark: a rail and two blocks — the letter B built from
rectangles only, and the same shape as the product (a spine with volumes
hanging off it). One fill, so `currentColor` themes it everywhere: the
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+51 -3
View File
@@ -150,6 +150,11 @@ button, input, a.btn { font-family: inherit; }
display: grid; place-items: center; font-size: 10px; font-weight: 700;
color: #0a0b0d; letter-spacing: -.02em; text-transform: uppercase;
}
/* The project's lucide glyph sits inside the colored square. */
.proj-mark svg { width: 11px; height: 11px; }
/* Menu rows carry their own mark, so they need the same row layout the
trigger has. */
.proj-menu [data-slot="select-item"] { display: flex; align-items: center; gap: 8px; }
#projects .row .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
#projects .row:hover { background: var(--hover); color: var(--text); }
#projects .row.active { background: var(--glow); color: var(--accent-bright); }
@@ -352,11 +357,48 @@ button, input, a.btn { font-family: inherit; }
.danger-btn { display: inline-flex; align-items: center; height: 32px; padding: 0 14px; border-radius: var(--r-ctl); border: none; background: #b3382e; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
.danger-btn:hover { background: #c94336; }
/* Tailwind preflight is deliberately off (see tw.css), so copied shadcn
components miss two of its defaults: form controls inheriting the page font
and color (a bare <textarea> otherwise renders monospace, an <input> black),
and the `border` utility resolving to a themed color rather than
currentColor (a near-white hairline on every card). Supply both here, by
slot, so the primitives stay unedited. */
[data-slot="input"],
[data-slot="textarea"] { font: inherit; color: var(--text); }
/* An invalid field keeps a red ring while focused, the way .admin's do —
the accent ring otherwise contradicts the red message below it. */
[data-slot="input"][aria-invalid="true"]:focus-visible,
[data-slot="textarea"][aria-invalid="true"]:focus-visible { border-color: var(--del); }
[data-slot="card"],
[data-slot="dropdown-menu-content"] { border-color: var(--border); }
/* ---- project settings ---- */
/* Irreversible actions get their own ruled-off section, well below the facts. */
.ps-danger { margin-top: 36px; padding-top: 20px; border-top: 1px solid var(--border); }
.ps-danger h3 { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: #d2695e; font-weight: 600; margin: 0 0 8px; }
/* Sectioned cards: General (editable), About (facts), Danger zone. */
.project-settings { display: flex; flex-direction: column; gap: 14px; }
.project-settings > h2 { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.ps-form { display: flex; flex-direction: column; gap: 18px; }
.ps-field { display: flex; flex-direction: column; gap: 7px; }
.ps-field label { font-size: 12.5px; color: var(--text-dim); }
.ps-opt { color: var(--text-ghost); font-weight: 400; }
.ps-icon-row { display: flex; align-items: center; gap: 10px; }
.ps-icon-row .proj-mark { width: 26px; height: 26px; border-radius: 7px; }
.ps-icon-row .proj-mark svg { width: 15px; height: 15px; }
.ps-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.ps-meta .field-err { flex: 0 1 auto; margin: 0; }
.ps-count { font-size: 11.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.ps-actions { display: flex; justify-content: flex-end; }
/* Icon picker: a grid inside the existing dropdown-menu, no new primitive. */
.ps-icon-grid { display: grid; grid-template-columns: repeat(6, 30px); gap: 4px; padding: 8px; }
.ps-icon-cell { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 7px; border: 1px solid transparent; background: none; color: var(--text-dim); cursor: pointer; }
.ps-icon-cell svg { width: 16px; height: 16px; }
.ps-icon-cell:hover { background: var(--hover); color: var(--text); }
.ps-icon-cell.active { border-color: var(--accent); color: var(--accent-bright); }
/* Irreversible actions keep their own, clearly-marked card. */
.ps-danger [data-slot="card-title"] { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: #d2695e; font-weight: 600; }
.ps-danger p { color: var(--text-dim); font-size: 13px; margin: 0 0 14px; max-width: 52ch; line-height: 1.55; }
.ps-facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 0; font-size: 13px; }
.ps-facts dt { color: var(--text-faint); }
.ps-facts dd { margin: 0; color: var(--text-dim); }
/* ---- admin panels ---- */
/* width comes from .page (app) — see #content */
@@ -490,6 +532,12 @@ a.ai-main:hover { color: var(--accent); }
/* .insights width comes from .page (app) */
.in-title { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.in-title .in-scope { color: var(--text-ghost); font-weight: 500; font-size: 15px; }
/* The project dashboard title carries the project's mark; its description
(when it has one) sits directly under the name. */
.gd-head { display: flex; align-items: center; gap: 9px; }
.gd-head .proj-mark { width: 22px; height: 22px; border-radius: 6px; }
.gd-head .proj-mark svg { width: 13px; height: 13px; }
.in-desc { color: var(--text-dim); font-size: 13.5px; line-height: 1.55; margin: 0 0 10px; max-width: 62ch; }
.in-lens { display: flex; gap: 6px; margin: 0 0 14px; }
.in-lens-btn { font: inherit; font-size: 12px; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: none; color: var(--text-faint); cursor: pointer; }
.in-lens-btn:hover { color: var(--text); }
+117
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
)
@@ -64,6 +65,52 @@ func TestProjectLifecycle(t *testing.T) {
if err := db.Rename(p.ID, "docs"); err == nil {
t.Fatal("rename to an existing org-name must be refused")
}
// Partial update: only the fields you pass move.
ptr := func(s string) *string { return &s }
if err := db.Update(p.ID, nil, ptr("the team handbook"), ptr("book-open")); err != nil {
t.Fatal(err)
}
got, _ := db.Get(p.ID)
if got.Name != "handbook" || got.Description != "the team handbook" || got.Icon != "book-open" {
t.Fatalf("update: %+v", got)
}
// icon-only update leaves name and description alone
if err := db.Update(p.ID, nil, nil, ptr("users")); err != nil {
t.Fatal(err)
}
if got, _ = db.Get(p.ID); got.Name != "handbook" || got.Description != "the team handbook" || got.Icon != "users" {
t.Fatalf("icon-only update: %+v", got)
}
// present-and-empty clears; absent does not
if err := db.Update(p.ID, nil, ptr(""), ptr("")); err != nil {
t.Fatal(err)
}
if got, _ = db.Get(p.ID); got.Description != "" || got.Icon != "" || got.Name != "handbook" {
t.Fatalf("clear: %+v", got)
}
for _, tc := range []struct {
what string
name, desc, icon *string
}{
{"empty name", ptr(" "), nil, nil},
{"name over 120", ptr(strings.Repeat("x", 121)), nil, nil},
{"sibling collision", ptr("docs"), nil, nil},
{"description over 280", nil, ptr(strings.Repeat("d", 281)), nil},
{"icon uppercase", nil, nil, ptr("Folder")},
{"icon with space", nil, nil, ptr("a b")},
{"icon over 32", nil, nil, ptr(strings.Repeat("a", 33))},
} {
if err := db.Update(p.ID, tc.name, tc.desc, tc.icon); err == nil {
t.Fatalf("%s: expected an error", tc.what)
}
}
// a rejected update leaves the record untouched
if got, _ = db.Get(p.ID); got.Name != "handbook" || got.Description != "" || got.Icon != "" {
t.Fatalf("rejected updates mutated the project: %+v", got)
}
if err := db.Delete(p.ID); err != nil {
t.Fatal(err)
}
@@ -99,6 +146,76 @@ func TestAdminEndpointsOwnerOnly(t *testing.T) {
}
}
// PATCH /api/projects/{id} is a partial update: only the keys in the body
// move, present-and-empty clears, and every validation failure is a 400. The
// permission gate is unchanged — a non-member still gets what it got before.
func TestProjectUpdatePartial(t *testing.T) {
h, srv, alice, bob, pa := orgHubSrv(t)
patch := func(body string, c *http.Cookie) int {
return doAs(t, h, "PATCH", "/api/projects/"+pa.ID, []byte(body), c).Code
}
get := func() Project {
t.Helper()
p, ok := srv.Projects.Get(pa.ID)
if !ok {
t.Fatal("project vanished")
}
return p
}
if code := patch(`{"name":"notes"}`, alice); code != 200 {
t.Fatalf("rename: %d", code)
}
if p := get(); p.Name != "notes" || p.Description != "" || p.Icon != "" {
t.Fatalf("name-only patch touched other fields: %+v", p)
}
if code := patch(`{"icon":"book-open"}`, alice); code != 200 {
t.Fatalf("icon: %d", code)
}
if p := get(); p.Name != "notes" || p.Icon != "book-open" {
t.Fatalf("icon-only patch: %+v", p)
}
if code := patch(`{"description":"what support reads"}`, alice); code != 200 {
t.Fatalf("description: %d", code)
}
if p := get(); p.Description != "what support reads" || p.Icon != "book-open" {
t.Fatalf("description-only patch: %+v", p)
}
if code := patch(`{"description":""}`, alice); code != 200 {
t.Fatalf("clear description: %d", code)
}
if p := get(); p.Description != "" || p.Icon != "book-open" || p.Name != "notes" {
t.Fatalf("clearing description touched other fields: %+v", p)
}
// alice's org gets a sibling so the collision rule has something to hit
if rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "docs"}, alice); rec.Code != 200 {
t.Fatalf("create sibling: %d %s", rec.Code, rec.Body)
}
for _, tc := range []struct{ what, body string }{
{"empty name", `{"name":""}`},
{"long name", `{"name":"` + strings.Repeat("x", 121) + `"}`},
{"sibling collision", `{"name":"docs"}`},
{"long description", `{"description":"` + strings.Repeat("d", 281) + `"}`},
{"bad icon", `{"icon":"BookOpen"}`},
} {
if code := patch(tc.body, alice); code != http.StatusBadRequest {
t.Fatalf("%s: got %d, want 400", tc.what, code)
}
}
// and none of those touched the record
if p := get(); p.Name != "notes" || p.Description != "" || p.Icon != "book-open" {
t.Fatalf("rejected patches mutated the project: %+v", p)
}
// gate unchanged: a non-member gets 404 (the project doesn't exist for him)
if code := patch(`{"icon":"users"}`, bob); code == 200 {
t.Fatal("non-member updated a project")
}
}
// The invite→join→role→remove flow over HTTP, end to end.
func TestMemberManagementHTTP(t *testing.T) {
h, _, alice, bob, pa := orgHubSrv(t)
+73 -16
View File
@@ -6,22 +6,38 @@ import (
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
// Project is one synced project hosted by this server. Its storage lives
// under <root>/<id>/ in the object store; the id is permanent, the name is a
// renameable label.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Org string `json:"org,omitempty"` // owning organization
Created time.Time `json:"created"`
ID string `json:"id"`
Name string `json:"name"`
Org string `json:"org,omitempty"` // owning organization
Created time.Time `json:"created"`
Description string `json:"description,omitempty"` // optional one-line subtitle
Icon string `json:"icon,omitempty"` // optional lucide icon name
}
var projectIDRe = regexp.MustCompile(`^p-[0-9a-f]{8}$`)
// iconRe validates the *shape* of an icon name only. The list of icons a
// project may pick from lives in the frontend (shell.tsx's PROJECT_ICONS) —
// the server stores whatever kebab-case name it's given and the UI falls back
// to a placeholder for anything it doesn't know, so adding an icon never
// needs a server change.
var iconRe = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
const (
maxNameLen = 120
maxDescLen = 280
)
// ProjectDB is the server's project registry: an in-memory index over a
// MetaStore ProjectRepo. Reads are served from memory; every change is
// persisted as one record through the repo (file or SQL).
@@ -102,28 +118,63 @@ func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) {
return p, true, nil
}
// Rename changes a project's display name (its id and storage are permanent).
func (db *ProjectDB) Rename(id, name string) error {
name = trimName(name)
if name == "" {
return fmt.Errorf("project name must not be empty")
// Update changes a project's editable metadata. Each field is a pointer so
// that "absent" (nil, leave alone) is distinguishable from "present and
// empty" (clear it) — the whole point of a partial update. One lock, one
// repo write, whatever the caller changed.
func (db *ProjectDB) Update(id string, name, description, icon *string) error {
var newName, newDesc, newIcon string
if name != nil {
newName = trimText(*name, maxNameLen+1)
if newName == "" {
return fmt.Errorf("project name must not be empty")
}
if utf8.RuneCountInString(newName) > maxNameLen {
return fmt.Errorf("project name must be at most %d characters", maxNameLen)
}
}
if description != nil {
newDesc = trimText(*description, maxDescLen+1)
if utf8.RuneCountInString(newDesc) > maxDescLen {
return fmt.Errorf("project description must be at most %d characters", maxDescLen)
}
}
if icon != nil {
newIcon = strings.TrimSpace(*icon)
if newIcon != "" && !iconRe.MatchString(newIcon) {
return fmt.Errorf("invalid icon name %q", newIcon)
}
}
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
for _, other := range db.byID {
if other.ID != id && other.Name == name && other.Org == p.Org {
return fmt.Errorf("a project named %q already exists in this organization", name)
if name != nil {
for _, other := range db.byID {
if other.ID != id && other.Name == newName && other.Org == p.Org {
return fmt.Errorf("a project named %q already exists in this organization", newName)
}
}
p.Name = newName
}
if description != nil {
p.Description = newDesc
}
if icon != nil {
p.Icon = newIcon
}
p.Name = name
db.byID[id] = p
return db.repo.Put(p)
}
// Rename changes a project's display name (its id and storage are permanent).
func (db *ProjectDB) Rename(id, name string) error {
return db.Update(id, &name, nil, nil)
}
// Delete removes a project from the registry. Its storage prefix (blobs,
// journals) is left in the object store — the id is retired, not scrubbed —
// so the caller decides whether to reclaim that space out of band.
@@ -150,7 +201,13 @@ func (db *ProjectDB) SetOrg(id, org string) error {
return db.repo.Put(p)
}
func trimName(s string) string {
// trimName normalizes a name on the *creation* path, where an over-long name
// is silently truncated rather than rejected (bdrive init must not fail on a
// long folder name). Update is stricter — see maxNameLen.
func trimName(s string) string { return trimText(s, 128) }
// trimText strips line breaks and outer spaces, then truncates to max runes.
func trimText(s string, max int) string {
out := make([]rune, 0, len(s))
for _, r := range s {
if r == '\n' || r == '\r' || r == '\t' {
@@ -164,8 +221,8 @@ func trimName(s string) string {
for len(out) > 0 && out[len(out)-1] == ' ' {
out = out[:len(out)-1]
}
if len(out) > 128 {
out = out[:128]
if len(out) > max {
out = out[:max]
}
return string(out)
}
+1 -1
View File
@@ -347,7 +347,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/orgs/{org}/shares", s.handleOrgShares)
mux.HandleFunc("POST /api/invites/{token}", s.handleInviteAccept)
mux.HandleFunc("PATCH /api/projects/{project}", s.handleProjectRename)
mux.HandleFunc("PATCH /api/projects/{project}", s.handleProjectUpdate)
mux.HandleFunc("DELETE /api/projects/{project}", s.handleProjectDelete)
mux.HandleFunc("GET /api/admin/policy", s.handleAdminPolicy)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-C7BeLQJ1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4T6dCJop.css">
<script type="module" crossorigin src="/assets/index-C2CHlQCN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FkLsvBWJ.css">
</head>
<body>
<div id="root"></div>