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 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 { 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<unknown>(null);
const [busy, setBusy] = useState(false);
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({
queryKey: ['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;
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 (
<div className="user-menu" ref={menuRef}>
<button
type="button"
className="user-menu__trigger"
className="user-menu__trigger pond-switcher__trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('layout.pondSwitcher.label')}
onClick={() => setOpen(!open)}
onClick={() => (open ? close() : setOpen(true))}
>
{current?.name ?? t('layout.pondSwitcher.trigger')}
</button>
{open && (
{open && !creating && (
<div className="user-menu__list" role="menu">
{ponds.data.map((pond) => (
<Link
key={pond.id}
role="menuitem"
to={`/p/${pond.slug}`}
onClick={() => setOpen(false)}
>
<Link key={pond.id} role="menuitem" to={`/p/${pond.slug}`} onClick={close}>
{pond.name}
</Link>
))}
<button
type="button"
role="menuitem"
className="pond-switcher__create"
onClick={() => setCreating(true)}
>
{t('pond.create.menuItem')}
</button>
</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>
);
}

View File

@ -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')}
</a>
</section>
{canModify && pond.data.type === 'shared' && (
<DeletePondSection pondId={pond.data.id} pondName={pond.data.name} />
)}
</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 {
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) */

View File

@ -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/<id>` — 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.

View File

@ -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 AZ, 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

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}