dorfteich/apps/web/src/layout/PondSwitcher.tsx
Claude Fable 5 627f128ab8
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
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 19:15:34 +02:00

144 lines
4.6 KiB
TypeScript

import type { PondView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
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)
* — 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);
const close = (): void => {
setOpen(false);
setCreating(false);
setError(null);
};
useDismissable(menuRef, open, close);
const ponds = useQuery({
queryKey: ['ponds'],
queryFn: () => apiGet<PondView[]>('/ponds'),
enabled: Boolean(user),
});
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 pond-switcher__trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('layout.pondSwitcher.label')}
onClick={() => (open ? close() : setOpen(true))}
>
{current?.name ?? t('layout.pondSwitcher.trigger')}
</button>
{open && !creating && (
<div className="user-menu__list" role="menu">
{ponds.data.map((pond) => (
<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>
);
}