Pond lifecycle in the UI: create shared ponds, delete from settings
Some checks failed
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Failing after 11s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m40s
CI / Import/export fidelity gate (push) Successful in 54s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-12 19:15:34 +02:00
parent 0c9d44e9c9
commit 627f128ab8
9 changed files with 331 additions and 22 deletions

View File

@ -98,3 +98,41 @@ test('non-admin members see the member list read-only', async ({ browser }) => {
await owner.close(); await owner.close();
await member.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();
});

View File

@ -1,22 +1,42 @@
import type { PondView } from '@dorfteich/shared'; 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 { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { 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 { useDismissable } from '../lib/use-dismissable';
import { useCurrentPondRoute } from './use-pond-route'; 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 { export function PondSwitcher(): React.JSX.Element | null {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const { pondSlug } = useCurrentPondRoute(); const { pondSlug } = useCurrentPondRoute();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useDismissable(menuRef, open, () => setOpen(false));
const close = (): void => {
setOpen(false);
setCreating(false);
setError(null);
};
useDismissable(menuRef, open, close);
const ponds = useQuery({ const ponds = useQuery({
queryKey: ['ponds'], queryKey: ['ponds'],
queryFn: () => apiGet<PondView[]>('/ponds'), queryFn: () => apiGet<PondView[]>('/ponds'),
@ -26,32 +46,98 @@ export function PondSwitcher(): React.JSX.Element | null {
if (!ponds.data || ponds.data.length === 0) return null; if (!ponds.data || ponds.data.length === 0) return null;
const current = ponds.data.find((pond) => pond.slug === pondSlug); const current = ponds.data.find((pond) => pond.slug === pondSlug);
const create = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setBusy(true);
try {
const pond = await apiPost<PondView>('/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 ( return (
<div className="user-menu" ref={menuRef}> <div className="user-menu" ref={menuRef}>
<button <button
type="button" type="button"
className="user-menu__trigger" className="user-menu__trigger pond-switcher__trigger"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
aria-label={t('layout.pondSwitcher.label')} aria-label={t('layout.pondSwitcher.label')}
onClick={() => setOpen(!open)} onClick={() => (open ? close() : setOpen(true))}
> >
{current?.name ?? t('layout.pondSwitcher.trigger')} {current?.name ?? t('layout.pondSwitcher.trigger')}
</button> </button>
{open && ( {open && !creating && (
<div className="user-menu__list" role="menu"> <div className="user-menu__list" role="menu">
{ponds.data.map((pond) => ( {ponds.data.map((pond) => (
<Link <Link key={pond.id} role="menuitem" to={`/p/${pond.slug}`} onClick={close}>
key={pond.id}
role="menuitem"
to={`/p/${pond.slug}`}
onClick={() => setOpen(false)}
>
{pond.name} {pond.name}
</Link> </Link>
))} ))}
<button
type="button"
role="menuitem"
className="pond-switcher__create"
onClick={() => setCreating(true)}
>
{t('pond.create.menuItem')}
</button>
</div> </div>
)} )}
{open && creating && (
<form className="user-menu__list pond-switcher__form" onSubmit={(e) => void create(e)}>
<h3>{t('pond.create.title')}</h3>
<FormError error={error} />
<label>
{t('pond.create.nameLabel')}
<input
type="text"
value={name}
required
maxLength={80}
autoFocus
onChange={(e) => setName(e.target.value)}
/>
</label>
<label>
{t('pond.create.descriptionLabel')}
<input
type="text"
value={description}
maxLength={500}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<p className="pond-switcher__hint">{t('pond.create.quotaHint')}</p>
<div className="pond-switcher__actions">
<button type="submit" className="button" disabled={busy || name.trim() === ''}>
{t('pond.create.submit')}
</button>
<button
type="button"
className="button button--outline"
onClick={() => {
setCreating(false);
setError(null);
}}
>
{t('pond.create.cancel')}
</button>
</div>
</form>
)}
</div> </div>
); );
} }

View File

@ -16,6 +16,7 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns
import { PondFileManager } from '../files/PondFileManager'; import { PondFileManager } from '../files/PondFileManager';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection';
import { PondPluginSettings } from '../plugins/PondPluginSettings'; import { PondPluginSettings } from '../plugins/PondPluginSettings';
/** /**
@ -126,6 +127,9 @@ export function PondSettingsPage(): React.JSX.Element {
{tExport('pond.zip')} {tExport('pond.zip')}
</a> </a>
</section> </section>
{canModify && pond.data.type === 'shared' && (
<DeletePondSection pondId={pond.data.id} pondName={pond.data.name} />
)}
</div> </div>
); );
} }

View File

@ -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<unknown>(null);
const [busy, setBusy] = useState(false);
const remove = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setBusy(true);
try {
await apiDelete(`/ponds/${pondId}`);
await queryClient.invalidateQueries({ queryKey: ['ponds'] });
navigate('/');
} catch (err) {
setError(err);
setBusy(false);
}
};
return (
<section className="pond-delete">
<h2>{t('pond.delete.title')}</h2>
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
<form onSubmit={(e) => void remove(e)}>
<FormError error={error} />
<label>
{t('pond.delete.confirmLabel', { name: pondName })}
<input
type="text"
value={confirm}
autoComplete="off"
onChange={(e) => setConfirm(e.target.value)}
/>
</label>
<button
type="submit"
className="button button--danger pond-delete__submit"
disabled={busy || confirm !== pondName}
>
{t('pond.delete.submit')}
</button>
</form>
</section>
);
}

View File

@ -2627,7 +2627,81 @@ button {
.button--danger { .button--danger {
border-color: var(--color-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) */ /* API tokens (issue #104) */

View File

@ -8,8 +8,9 @@ bar (visible on pond routes when you may modify the pond).
## Ponds in one minute ## Ponds in one minute
Every member gets a **personal pond** automatically. Additional Every member gets a **personal pond** automatically. Additional
**shared ponds** are created through the API (`POST /api/v1/ponds`) and **shared ponds** are created via **"+ New pond"** at the bottom of the
are subject to the per-user quota the site admin sets ("additional 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. shared ponds per user", default 0). The creator becomes the pond admin.
## Name, description, appearance ## Name, description, appearance
@ -98,7 +99,7 @@ against the pond's quota; the current usage is shown.
## Export and deletion ## Export and deletion
- **Export**: the whole pond as a ZIP of Markdown files plus media. - **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 - **Delete pond**: the danger section at the bottom of the pond
their admin via the API (`DELETE /api/v1/ponds/<id>` — there is no UI settings moves a shared pond to the site-level trash (type the pond
button yet); a site admin can restore them. Your personal pond cannot name to confirm); a site admin can restore it. Your personal pond
be deleted — it is your account's home. cannot be deleted — it is your account's home.

View File

@ -19,7 +19,8 @@ instance administration the [site-admin guide](site-admin-guide.md).
## Ponds and pages ## Ponds and pages
- The **pond switcher** in the top bar moves you between the ponds you - 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 AZ, by creation date, or drag them into a manual order (the sort them AZ, by creation date, or drag them into a manual order (the sort
mode is a pond setting). mode is a pond setting).
- **+ New page** at the bottom of the sidebar creates a page. Page - **+ New page** at the bottom of the sidebar creates a page. Page

View File

@ -48,5 +48,23 @@
"title": "Seite nicht gefunden", "title": "Seite nicht gefunden",
"body": "Die aufgerufene Adresse existiert nicht.", "body": "Die aufgerufene Adresse existiert nicht.",
"home": "Zurück zur Startseite" "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."
}
} }
} }

View File

@ -48,5 +48,23 @@
"title": "Page not found", "title": "Page not found",
"body": "The address you opened does not exist.", "body": "The address you opened does not exist.",
"home": "Back to the start page" "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."
}
} }
} }