From 65491f9576eee7fd8c4c844404c4f6c849f80c24 Mon Sep 17 00:00:00 2001 From: "Snow W. Lee (Sungwon)" Date: Mon, 27 Jul 2026 09:56:59 +0900 Subject: [PATCH] feat(hub): rename, describe and pick an icon for a project, from its own Settings page (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- architecture/webapp-server.md | 3 +- internal/webapp/admin.go | 13 +- internal/webapp/db_conformance_test.go | 10 + internal/webapp/db_sql.go | 45 ++- internal/webapp/frontend/e2e/admin.spec.ts | 25 +- internal/webapp/frontend/e2e/home.spec.ts | 58 +++- internal/webapp/frontend/src/api/types.ts | 3 + internal/webapp/frontend/src/apps/HubApp.tsx | 1 - .../frontend/src/components/ConnectGuide.tsx | 14 +- .../frontend/src/components/OrgAdmin.tsx | 55 +--- .../frontend/src/components/ProjectNav.tsx | 32 +- .../src/components/ProjectSettings.tsx | 306 +++++++++++++++--- .../webapp/frontend/src/components/shell.tsx | 69 ++++ .../frontend/src/components/ui/card.tsx | 92 ++++++ .../frontend/src/components/ui/separator.tsx | 28 ++ .../frontend/src/components/ui/textarea.tsx | 18 ++ internal/webapp/frontend/src/style.css | 54 +++- internal/webapp/lifecycle_test.go | 117 +++++++ internal/webapp/projects.go | 89 ++++- internal/webapp/server.go | 2 +- .../webapp/static/assets/index-4T6dCJop.css | 1 - .../webapp/static/assets/index-C2CHlQCN.js | 121 +++++++ .../webapp/static/assets/index-C7BeLQJ1.js | 121 ------- .../webapp/static/assets/index-FkLsvBWJ.css | 1 + internal/webapp/static/index.html | 4 +- 25 files changed, 1015 insertions(+), 267 deletions(-) create mode 100644 internal/webapp/frontend/src/components/ui/card.tsx create mode 100644 internal/webapp/frontend/src/components/ui/separator.tsx create mode 100644 internal/webapp/frontend/src/components/ui/textarea.tsx delete mode 100644 internal/webapp/static/assets/index-4T6dCJop.css create mode 100644 internal/webapp/static/assets/index-C2CHlQCN.js delete mode 100644 internal/webapp/static/assets/index-C7BeLQJ1.js create mode 100644 internal/webapp/static/assets/index-FkLsvBWJ.css diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index 1c9679c..59302d6 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -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 { diff --git a/internal/webapp/admin.go b/internal/webapp/admin.go index dc8e9dd..5f3e025 100644 --- a/internal/webapp/admin.go +++ b/internal/webapp/admin.go @@ -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 } diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go index 8b0548a..684fbcc 100644 --- a/internal/webapp/db_conformance_test.go +++ b/internal/webapp/db_conformance_test.go @@ -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) diff --git a/internal/webapp/db_sql.go b/internal/webapp/db_sql.go index 100d64f..1fa0103 100644 --- a/internal/webapp/db_sql.go +++ b/internal/webapp/db_sql.go @@ -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 { diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index 184de0b..b5f3001 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -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 }); diff --git a/internal/webapp/frontend/e2e/home.spec.ts b/internal/webapp/frontend/e2e/home.spec.ts index b316f72..1754bbe 100644 --- a/internal/webapp/frontend/e2e/home.spec.ts +++ b/internal/webapp/frontend/e2e/home.spec.ts @@ -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 }) => { diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 5e69a28..fe297ac 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -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 { diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index 8499363..bf1344e 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -165,7 +165,6 @@ export default function HubApp({ config }: { config: ServerConfig }) { org={routeOrg} projects={projects} myEmail={config.me?.email || ""} - onProjectsChanged={refresh} /> ), } diff --git a/internal/webapp/frontend/src/components/ConnectGuide.tsx b/internal/webapp/frontend/src/components/ConnectGuide.tsx index 651e3ba..1878d41 100644 --- a/internal/webapp/frontend/src/components/ConnectGuide.tsx +++ b/internal/webapp/frontend/src/components/ConnectGuide.tsx @@ -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 (
-

{project.name}

+

+ + {project.name} +

+ {project.description &&

{project.description}

}

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 diff --git a/internal/webapp/frontend/src/components/OrgAdmin.tsx b/internal/webapp/frontend/src/components/OrgAdmin.tsx index a662d9c..92602b4 100644 --- a/internal/webapp/frontend/src/components/OrgAdmin.tsx +++ b/internal/webapp/frontend/src/components/OrgAdmin.tsx @@ -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; }) { const qc = useQueryClient(); const owner = org.role === "owner"; @@ -145,51 +143,20 @@ export function OrgAdmin({

Members

- {!owner && ( - <> -

Projects

-
- {orgProjects.length === 0 &&
No projects yet.
} - {orgProjects.map((p) => ( -
- {p.name} -
- ))} + {/* Read-only for everybody: a project is renamed (and deleted) from its + own Settings page, so this list is the same whatever your role. */} +

Projects

+
+ {orgProjects.length === 0 &&
No projects yet.
} + {orgProjects.map((p) => ( +
+ {p.name}
- - )} + ))} +
{owner && ( <> -

Projects

-
- {orgProjects.length === 0 &&
No projects yet.
} - {orgProjects.map((p) => ( -
- {p.name} - - {/* Delete lives on the project's own Settings page, behind - type-the-name — one way to delete, and it's the hard one. */} -
- ))} -
-

Invite links

+ + + {/* Real menu items, so the grid closes on pick and works + from the keyboard like every other menu in the app. */} + form.setValue("icon", "", { shouldDirty: true })} + > + + + {Object.keys(PROJECT_ICONS).map((name) => ( + form.setValue("icon", name, { shouldDirty: true })} + > + + + ))} + + +
+
+ +
+ + + {form.formState.errors.name && ( + + {form.formState.errors.name.message} + + )} +
+ +
+ +