From 627f128ab8bf7d2b1f481f8ddab162d0beb46c58 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 12 Jul 2026 19:15:34 +0200 Subject: [PATCH] Pond lifecycle in the UI: create shared ponds, delete from settings The pond switcher grows a "+ New pond" entry with an inline form (name + optional description, quota errors surfaced translated); the pond settings of shared ponds end in a danger section that moves the pond to the site-level trash after typing its name to confirm. Personal ponds keep hiding the section. .button--danger is now a solid red button (also fixes the admin restore button, which showed red text on the accent-green background). Manuals no longer call these actions API-only; covered by a members-pack e2e test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/web/e2e/members.spec.ts | 38 ++++++++ apps/web/src/layout/PondSwitcher.tsx | 114 ++++++++++++++++++++--- apps/web/src/pages/PondSettingsPage.tsx | 4 + apps/web/src/ponds/DeletePondSection.tsx | 69 ++++++++++++++ apps/web/src/styles/base.css | 76 ++++++++++++++- docs/manual/pond-admin-guide.md | 13 +-- docs/manual/user-guide.md | 3 +- packages/shared/i18n/de/common.json | 18 ++++ packages/shared/i18n/en/common.json | 18 ++++ 9 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/ponds/DeletePondSection.tsx diff --git a/apps/web/e2e/members.spec.ts b/apps/web/e2e/members.spec.ts index 179896d..dd823ba 100644 --- a/apps/web/e2e/members.spec.ts +++ b/apps/web/e2e/members.spec.ts @@ -98,3 +98,41 @@ test('non-admin members see the member list read-only', async ({ browser }) => { await owner.close(); await member.close(); }); + +test('shared ponds are created from the switcher and deleted from settings', async ({ + browser, +}) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const page = await owner.newPage(); + await page.goto('/'); + + // Create via the "+ New pond" flow in the pond switcher. + const pondName = `Switcher ${Date.now()}`; + // The switcher only mounts once the ponds query resolves — wait for its + // dedicated class instead of grabbing the first generic menu trigger. + await page.locator('.pond-switcher__trigger').click(); + await page.locator('.pond-switcher__create').click(); + await page.locator('.pond-switcher__form input').first().fill(pondName); + await page.locator('.pond-switcher__actions button[type="submit"]').click(); + // Creation lands on the new pond's home. + await expect(page).toHaveURL(/\/p\/switcher-/); + + // Delete from the danger section — the button stays disabled until the + // typed confirmation matches the pond name exactly. + const slug = new URL(page.url()).pathname.split('/')[2]; + await page.goto(`/p/${slug}/settings`); + const danger = page.locator('.pond-delete'); + await expect(danger).toBeVisible(); + await expect(danger.locator('.pond-delete__submit')).toBeDisabled(); + await danger.locator('input').fill(pondName); + await danger.locator('.pond-delete__submit').click(); + await expect(page).toHaveURL(/\/$|\/p\/fixture-user/); + expect((await owner.request.get(`/api/v1/ponds/${slug}`)).status()).toBe(404); + + // The personal pond never offers deletion. + await page.goto('/p/fixture-user/settings'); + await expect(page.locator('.pond-export')).toBeVisible(); + await expect(page.locator('.pond-delete')).toHaveCount(0); + + await owner.close(); +}); diff --git a/apps/web/src/layout/PondSwitcher.tsx b/apps/web/src/layout/PondSwitcher.tsx index 9370538..4f14329 100644 --- a/apps/web/src/layout/PondSwitcher.tsx +++ b/apps/web/src/layout/PondSwitcher.tsx @@ -1,22 +1,42 @@ import type { PondView } from '@dorfteich/shared'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; -import { apiGet } from '../lib/api'; +import { FormError } from '../components/forms'; +import { apiGet, apiPost } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; import { useCurrentPondRoute } from './use-pond-route'; -/** Top-bar dropdown to switch between the signed-in user's ponds (issue #26). */ +/** + * Top-bar dropdown to switch between the signed-in user's ponds (issue #26) + * — and to create a new shared pond right from the menu. Creation is + * quota-gated server-side (`additional shared ponds per user`); a rejected + * attempt surfaces the translated error instead of hiding the option. + */ export function PondSwitcher(): React.JSX.Element | null { const { t } = useTranslation(); const { user } = useAuth(); const { pondSlug } = useCurrentPondRoute(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); const menuRef = useRef(null); - useDismissable(menuRef, open, () => setOpen(false)); + + const close = (): void => { + setOpen(false); + setCreating(false); + setError(null); + }; + useDismissable(menuRef, open, close); + const ponds = useQuery({ queryKey: ['ponds'], queryFn: () => apiGet('/ponds'), @@ -26,32 +46,98 @@ export function PondSwitcher(): React.JSX.Element | null { if (!ponds.data || ponds.data.length === 0) return null; const current = ponds.data.find((pond) => pond.slug === pondSlug); + const create = async (event: React.FormEvent): Promise => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const pond = await apiPost('/ponds', { + name: name.trim(), + description: description.trim(), + }); + await queryClient.invalidateQueries({ queryKey: ['ponds'] }); + close(); + setName(''); + setDescription(''); + navigate(`/p/${pond.slug}`); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + }; + return (
- {open && ( + {open && !creating && (
{ponds.data.map((pond) => ( - setOpen(false)} - > + {pond.name} ))} +
)} + {open && creating && ( +
void create(e)}> +

{t('pond.create.title')}

+ + + +

{t('pond.create.quotaHint')}

+
+ + +
+ + )}
); } diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index d467236..988062b 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -16,6 +16,7 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns import { PondFileManager } from '../files/PondFileManager'; import { apiGet } from '../lib/api'; import { MemberManager } from '../members/MemberManager'; +import { DeletePondSection } from '../ponds/DeletePondSection'; import { PondPluginSettings } from '../plugins/PondPluginSettings'; /** @@ -126,6 +127,9 @@ export function PondSettingsPage(): React.JSX.Element { {tExport('pond.zip')} + {canModify && pond.data.type === 'shared' && ( + + )} ); } diff --git a/apps/web/src/ponds/DeletePondSection.tsx b/apps/web/src/ponds/DeletePondSection.tsx new file mode 100644 index 0000000..d55c0f3 --- /dev/null +++ b/apps/web/src/ponds/DeletePondSection.tsx @@ -0,0 +1,69 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { FormError } from '../components/forms'; +import { apiDelete } from '../lib/api'; + +/** + * The pond's danger zone: move a shared pond to the site-level trash + * (`DELETE /ponds/:id`, Pond-Admin-gated in the api; a site admin can + * restore it). Type-to-confirm with the pond name — the same backstop + * pattern as the backup restore. Personal ponds never render this + * section: they are the account's home and the api refuses anyway. + */ +export function DeletePondSection({ + pondId, + pondName, +}: { + pondId: string; + pondName: string; +}): React.JSX.Element { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const remove = async (event: React.FormEvent): Promise => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + await apiDelete(`/ponds/${pondId}`); + await queryClient.invalidateQueries({ queryKey: ['ponds'] }); + navigate('/'); + } catch (err) { + setError(err); + setBusy(false); + } + }; + + return ( +
+

{t('pond.delete.title')}

+

{t('pond.delete.hint')}

+
void remove(e)}> + + + + +
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2041659..ccf58aa 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2627,7 +2627,81 @@ button { .button--danger { border-color: var(--color-danger); - color: var(--color-danger); + background: var(--color-danger); + color: var(--color-accent-contrast); +} + +.button--danger:hover:not(:disabled) { + background: #8a0718; +} + +/* Pond creation form in the switcher + pond danger zone */ +.pond-switcher__create { + display: block; + width: 100%; + text-align: left; + border: none; + border-top: 1px solid var(--color-border); + background: none; + padding: var(--space-2) var(--space-3); + cursor: pointer; + font: inherit; + color: var(--color-accent); +} + +.pond-switcher__form { + padding: var(--space-3); + min-width: 18rem; +} + +.pond-switcher__form label { + display: block; + margin-top: var(--space-2); + font-weight: 600; +} + +.pond-switcher__form input { + display: block; + width: 100%; + margin-top: var(--space-1); + box-sizing: border-box; +} + +.pond-switcher__hint { + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.pond-switcher__actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.pond-delete { + border-top: 1px solid var(--color-border); + margin-top: var(--space-6); + padding-top: var(--space-4); +} + +.pond-delete__hint { + color: var(--color-text-muted); +} + +.pond-delete label { + display: block; + font-weight: 600; + margin: var(--space-2) 0; +} + +.pond-delete input { + display: block; + margin-top: var(--space-1); + width: min(24rem, 100%); +} + +.pond-delete__submit { + margin-top: var(--space-2); } /* API tokens (issue #104) */ diff --git a/docs/manual/pond-admin-guide.md b/docs/manual/pond-admin-guide.md index bbcecbe..bc40772 100644 --- a/docs/manual/pond-admin-guide.md +++ b/docs/manual/pond-admin-guide.md @@ -8,8 +8,9 @@ bar (visible on pond routes when you may modify the pond). ## Ponds in one minute Every member gets a **personal pond** automatically. Additional -**shared ponds** are created through the API (`POST /api/v1/ponds`) and -are subject to the per-user quota the site admin sets ("additional +**shared ponds** are created via **"+ New pond"** at the bottom of the +pond switcher in the top bar (or through the API, `POST /api/v1/ponds`) +and are subject to the per-user quota the site admin sets ("additional shared ponds per user", default 0). The creator becomes the pond admin. ## Name, description, appearance @@ -98,7 +99,7 @@ against the pond's quota; the current usage is shown. ## Export and deletion - **Export**: the whole pond as a ZIP of Markdown files plus media. -- **Delete pond**: shared ponds can be moved to the site-level trash by - their admin via the API (`DELETE /api/v1/ponds/` — there is no UI - button yet); a site admin can restore them. Your personal pond cannot - be deleted — it is your account's home. +- **Delete pond**: the danger section at the bottom of the pond + settings moves a shared pond to the site-level trash (type the pond + name to confirm); a site admin can restore it. Your personal pond + cannot be deleted — it is your account's home. diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md index 32e600f..cec088b 100644 --- a/docs/manual/user-guide.md +++ b/docs/manual/user-guide.md @@ -19,7 +19,8 @@ instance administration the [site-admin guide](site-admin-guide.md). ## Ponds and pages - The **pond switcher** in the top bar moves you between the ponds you - can see. The **sidebar** lists the pages of the current pond — sort + can see — and **"+ New pond"** at its bottom creates a new shared pond + (subject to a quota the site admin sets). The **sidebar** lists the pages of the current pond — sort them A–Z, by creation date, or drag them into a manual order (the sort mode is a pond setting). - **+ New page** at the bottom of the sidebar creates a page. Page diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index 5f33908..b0b8587 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -48,5 +48,23 @@ "title": "Seite nicht gefunden", "body": "Die aufgerufene Adresse existiert nicht.", "home": "Zurück zur Startseite" + }, + "pond": { + "create": { + "menuItem": "+ Neuer Teich", + "title": "Gemeinsamen Teich anlegen", + "nameLabel": "Name", + "descriptionLabel": "Beschreibung (optional)", + "submit": "Teich anlegen", + "cancel": "Abbrechen", + "quotaHint": "Gemeinsame Teiche zählen gegen dein Kontingent — frag eine Site-Adminin/einen Site-Admin, falls das Anlegen abgelehnt wird." + }, + "delete": { + "title": "Teich löschen", + "hint": "Verschiebt diesen Teich mit allen Seiten in den Instanz-Papierkorb. Ein Site-Admin kann ihn wiederherstellen. Dein persönlicher Teich kann nicht gelöscht werden.", + "confirmLabel": "Zur Bestätigung den Teichnamen eintippen: {{name}}", + "submit": "Teich löschen", + "deleted": "Teich gelöscht." + } } } diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index 0d6af6b..616c8d7 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -48,5 +48,23 @@ "title": "Page not found", "body": "The address you opened does not exist.", "home": "Back to the start page" + }, + "pond": { + "create": { + "menuItem": "+ New pond", + "title": "Create a shared pond", + "nameLabel": "Name", + "descriptionLabel": "Description (optional)", + "submit": "Create pond", + "cancel": "Cancel", + "quotaHint": "Shared ponds count against your quota — ask a site admin if creation is rejected." + }, + "delete": { + "title": "Delete pond", + "hint": "Moves this pond with all of its pages to the site-level trash. A site admin can restore it. Your personal pond cannot be deleted.", + "confirmLabel": "Type the pond name to confirm: {{name}}", + "submit": "Delete pond", + "deleted": "Pond deleted." + } } }