Add pond sidebar with page list, sort modes, and pond switcher (#26)
All checks were successful
CD / Build and push images (push) Successful in 1m59s
CI / Lint, typecheck, test (push) Successful in 1m38s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s

GET /ponds/:id/pages lists a pond's pages ordered by the pond's
persisted sidebarSort setting (alpha/created; manual arrives with #45).
The sidebar consumes it to show the page list with an active-page
highlight, an owner-only sort switch (persists via the existing
PATCH /ponds/:id), and an inline "new page" flow. The top bar gains a
pond switcher; a new /p/:pondSlug route gives it somewhere to land,
redirecting to the pond's first page once loaded. Sidebar collapse
gains a Ctrl/Cmd+\ shortcut and a slightly refined transition.

Closes #26
This commit is contained in:
Claude Sonnet 5 2026-07-06 12:37:44 +02:00
parent 076883a9a6
commit 49beb45b3e
15 changed files with 651 additions and 9 deletions

View File

@ -39,6 +39,11 @@ export class PagesController {
return this.pages.create(request.user!, pondId, input); return this.pages.create(request.user!, pondId, input);
} }
@Get('ponds/:pondId/pages')
async list(@Param('pondId') pondId: string, @Req() request: AuthedRequest): Promise<PageView[]> {
return this.pages.list(request.user!, pondId);
}
@Get('pages/:id') @Get('pages/:id')
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> { async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
return this.pages.getState(request.user!, id); return this.pages.getState(request.user!, id);
@ -46,7 +51,7 @@ export class PagesController {
/** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route /** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route
* navigates to (issue #25); the pond id must already be known to the caller * navigates to (issue #25); the pond id must already be known to the caller
* (e.g. from `GET /ponds/:slug`). Listing pages by pond arrives with #26. */ * (e.g. from `GET /ponds/:slug`). */
@Get('ponds/:pondId/pages/:slug') @Get('ponds/:pondId/pages/:slug')
async getStateBySlug( async getStateBySlug(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,

View File

@ -250,6 +250,70 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
.expect(404); .expect(404);
}); });
it('lists pages sorted by the pond sidebar sort mode (issue #26)', async () => {
const titles = [`Zebra ${suffix}`, `Apple ${suffix}`, `Mango ${suffix}`];
const ids: string[] = [];
for (const title of titles) {
const res = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title })
.expect(201);
ids.push(res.body.id as string);
}
const [zebraId, appleId, mangoId] = ids;
// Default sort mode is 'alpha'.
const alphaList = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
const alphaOrder = (alphaList.body as { id: string }[])
.map((p) => p.id)
.filter((id) => ids.includes(id));
expect(alphaOrder).toEqual([appleId, mangoId, zebraId]);
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', ownerCookie)
.send({ sidebarSort: 'created' })
.expect(200);
const createdList = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
const createdOrder = (createdList.body as { id: string }[])
.map((p) => p.id)
.filter((id) => ids.includes(id));
expect(createdOrder).toEqual([zebraId, appleId, mangoId]);
// Sort mode is a pond setting, not per-caller: the outsider would see
// 'created' order too, but has no access to this pond in the first place.
await api().get(`/api/v1/ponds/${pondId}/pages`).set('Cookie', outsiderCookie).expect(404);
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', ownerCookie)
.send({ sidebarSort: 'alpha' })
.expect(200);
});
it('excludes soft-deleted pages from the list', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Deleted From List ${suffix}` })
.expect(201);
await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
const list = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect((list.body as { id: string }[]).some((p) => p.id === created.body.id)).toBe(false);
});
it('hides pages in foreign ponds (404, not 403)', async () => { it('hides pages in foreign ponds (404, not 403)', async () => {
const created = await api() const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)

View File

@ -11,7 +11,9 @@ import {
PageStateView, PageStateView,
PageView, PageView,
SavePageStateInput, SavePageStateInput,
SidebarSortMode,
UpdatePageInput, UpdatePageInput,
pondSettingsSchema,
slugify, slugify,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Page, Prisma, User } from '@prisma/client'; import { Page, Prisma, User } from '@prisma/client';
@ -104,6 +106,27 @@ export class PagesService {
return page; return page;
} }
private static readonly SORT_ORDER: Record<SidebarSortMode, Prisma.PageOrderByWithRelationInput> =
{
alpha: { title: 'asc' },
created: { createdAt: 'asc' },
// Manual reordering (drag-and-drop) arrives with #45; the fractional
// `sortKey` already reflects creation order in the meantime.
manual: { sortKey: 'asc' },
};
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26). */
async list(user: User, pondId: string): Promise<PageView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond);
const settings = pondSettingsSchema.parse(pond.settings ?? {});
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
});
return pages.map((page) => this.viewOf(page));
}
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> { async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond); this.access.assertCanModify(user, pond);

View File

@ -0,0 +1,147 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Pond sidebar pack (issue #26). Runs against the local dev stack (api +
* web), no Mailpit needed. Creates its own pages per test via the api and
* navigates straight to `/p/:pondSlug/:pageSlug` (or `/p/:pondSlug`).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function personalPond(
context: Awaited<ReturnType<typeof contextForUser>>,
): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function createPage(
context: Awaited<ReturnType<typeof contextForUser>>,
pondId: string,
title: string,
): Promise<{ id: string; slug: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, {
data: { title },
});
return created.json();
}
test('sort modes reorder the sidebar page list correctly', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
// Defensive: a previous failed run may have left this fixture pond on a
// non-default sort mode.
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'alpha' } });
const suffix = Date.now();
await createPage(context, pond.id, `Zebra ${suffix}`);
const apple = await createPage(context, pond.id, `Apple ${suffix}`);
await createPage(context, pond.id, `Mango ${suffix}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${apple.slug}`);
const items = page.locator('.sidebar__pages .sidebar__page');
await expect(items.filter({ hasText: `Zebra ${suffix}` })).toBeVisible();
async function orderOf(titles: string[]): Promise<number[]> {
const texts = await items.allTextContents();
return titles.map((title) => texts.findIndex((text) => text === title));
}
// Default pond setting is 'alpha': relative order of the three fixture
// pages must be ascending regardless of whatever other pages exist.
function expectAscending(order: number[]): void {
expect(order.every((v, i) => i === 0 || order[i - 1]! < v)).toBe(true);
}
await expect(async () => {
expectAscending(await orderOf([`Apple ${suffix}`, `Mango ${suffix}`, `Zebra ${suffix}`]));
}).toPass();
try {
await page.getByLabel(/sort pages|seiten sortieren/i).selectOption('created');
await expect(async () => {
expectAscending(await orderOf([`Zebra ${suffix}`, `Apple ${suffix}`, `Mango ${suffix}`]));
}).toPass();
// Reload: the sort mode persisted server-side, not just in local state.
await page.reload();
await expect(page.getByLabel(/sort pages|seiten sortieren/i)).toHaveValue('created');
await expect(async () => {
expectAscending(await orderOf([`Zebra ${suffix}`, `Apple ${suffix}`, `Mango ${suffix}`]));
}).toPass();
} finally {
// Reset so this fixture pond doesn't leak state into other tests/runs.
await page.getByLabel(/sort pages|seiten sortieren/i).selectOption('alpha');
}
await context.close();
});
test('active page is highlighted in the sidebar', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const suffix = Date.now();
const one = await createPage(context, pond.id, `Active One ${suffix}`);
const two = await createPage(context, pond.id, `Active Two ${suffix}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${one.slug}`);
const activeLink = page.locator('.sidebar__page--active');
await expect(activeLink).toHaveText(`Active One ${suffix}`);
await page.goto(`/p/${pond.slug}/${two.slug}`);
await expect(page.locator('.sidebar__page--active')).toHaveText(`Active Two ${suffix}`);
await context.close();
});
test('new-page flow: button opens a title prompt and the editor opens on create', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const suffix = Date.now();
const title = `Created via UI ${suffix}`;
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
await page.getByRole('button', { name: /new page|neue seite/i }).click();
await page.getByLabel(/title|titel/i).fill(title);
await page.getByRole('button', { name: /create|erstellen/i }).click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));
await expect(page.locator('.editor-page__title')).toHaveValue(title);
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
await context.close();
});
test('sidebar collapse state persists per user and via the keyboard shortcut', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const suffix = Date.now();
const created = await createPage(context, pond.id, `Collapse Test ${suffix}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${created.slug}`);
const sidebar = page.locator('nav.sidebar');
await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
await page.keyboard.press('ControlOrMeta+Backslash');
await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
await page.reload();
await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
await page.keyboard.press('ControlOrMeta+Backslash');
await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
await context.close();
});

View File

@ -6,6 +6,7 @@ import { AdminSettingsPage } from './pages/AdminSettingsPage';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage'; import { PageEditorPage } from './pages/PageEditorPage';
import { PondHomePage } from './pages/PondHomePage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
import { LoginPage } from './pages/auth/LoginPage'; import { LoginPage } from './pages/auth/LoginPage';
@ -30,6 +31,7 @@ export function App(): React.JSX.Element {
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="p/:pondSlug" element={<PondHomePage />} />
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} /> <Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
</Route> </Route>
<Route element={<RequireSiteAdmin />}> <Route element={<RequireSiteAdmin />}>

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
@ -11,6 +11,19 @@ export function AppLayout(): React.JSX.Element {
const [forcedHidden, setForcedHidden] = useState(false); const [forcedHidden, setForcedHidden] = useState(false);
const collapsed = sidebarCollapsed || forcedHidden; const collapsed = sidebarCollapsed || forcedHidden;
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
// which element has focus.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key === '\\') {
event.preventDefault();
setSidebarCollapsed(!sidebarCollapsed);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [sidebarCollapsed, setSidebarCollapsed]);
return ( return (
<SidebarChromeContext.Provider value={setForcedHidden}> <SidebarChromeContext.Provider value={setForcedHidden}>
<div className="app"> <div className="app">

View File

@ -0,0 +1,64 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { CreatePageInput, PageView, createPageInputSchema } from '@dorfteich/shared';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Field, FormError } from '../components/forms';
import { apiPost } from '../lib/api';
interface NewPageFormProps {
pondId: string;
pondSlug: string;
onCreated: () => void;
onCancel: () => void;
}
/** Inline "new page" prompt: title → create → editor opens (issue #26). */
export function NewPageForm({
pondId,
pondSlug,
onCreated,
onCancel,
}: NewPageFormProps): React.JSX.Element {
const { t } = useTranslation();
const navigate = useNavigate();
const [error, setError] = useState<unknown>(null);
const form = useForm<CreatePageInput>({ resolver: zodResolver(createPageInputSchema) });
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, input);
onCreated();
navigate(`/p/${pondSlug}/${page.slug}`);
} catch (err) {
setError(err);
}
});
return (
<form className="sidebar__new-page-form" onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<Field label={t('layout.sidebar.newPageTitle')} error={form.formState.errors.title?.message}>
<input
type="text"
autoFocus
{...form.register('title')}
onKeyDown={(event) => {
if (event.key === 'Escape') onCancel();
}}
/>
</Field>
<div className="sidebar__new-page-actions">
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('layout.sidebar.create')}
</button>
<button type="button" className="linklike" onClick={onCancel}>
{t('layout.sidebar.cancel')}
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,54 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
import { useCurrentPondRoute } from './use-pond-route';
/** Top-bar dropdown to switch between the signed-in user's ponds (issue #26). */
export function PondSwitcher(): React.JSX.Element | null {
const { t } = useTranslation();
const { user } = useAuth();
const { pondSlug } = useCurrentPondRoute();
const [open, setOpen] = useState(false);
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);
return (
<div className="user-menu">
<button
type="button"
className="user-menu__trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('layout.pondSwitcher.label')}
onClick={() => setOpen(!open)}
>
{current?.name ?? t('layout.pondSwitcher.trigger')}
</button>
{open && (
<div className="user-menu__list" role="menu">
{ponds.data.map((pond) => (
<Link
key={pond.id}
role="menuitem"
to={`/p/${pond.slug}`}
onClick={() => setOpen(false)}
>
{pond.name}
</Link>
))}
</div>
)}
</div>
);
}

View File

@ -1,23 +1,122 @@
import type { PageView, PondView, SidebarSortMode } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { apiGet, apiPatch } from '../lib/api';
import { NewPageForm } from './NewPageForm';
import { useCurrentPondRoute } from './use-pond-route';
interface SidebarProps { interface SidebarProps {
collapsed: boolean; collapsed: boolean;
} }
/** Modes offered in the switch; manual reordering (drag-and-drop) arrives with #45. */
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created'];
/** /**
* Left sidebar skeleton. The pond page list (issue #26) replaces the hint * Left sidebar: the current pond's page list, sort mode, active-page
* text; the collapse behavior and layout contract are final: collapsing * highlight, and "new page" flow (issue #26). Collapse behavior and the
* must not reflow the main content beyond reclaiming the width. * layout contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
*/ */
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element { export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth();
const { pondSlug, pageSlug } = useCurrentPondRoute();
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
enabled: pondSlug !== null,
});
const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
const isOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
async function setSortMode(mode: SidebarSortMode): Promise<void> {
if (!pond.data) return;
await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
}
return ( return (
<nav <nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'} className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed} aria-hidden={collapsed}
aria-label={t('layout.sidebar.label')} aria-label={t('layout.sidebar.label')}
> >
{!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p> <p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : (
<>
<div className="sidebar__header">
<h2 className="sidebar__pond-name">{pond.data.name}</h2>
{isOwner && (
<select
className="sidebar__sort"
aria-label={t('layout.sidebar.sortLabel')}
value={pond.data.settings.sidebarSort}
onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)}
>
{SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`layout.sidebar.sortMode.${mode}`)}
</option>
))}
</select>
)}
</div>
{pages.data && pages.data.length > 0 ? (
<ul className="sidebar__pages">
{pages.data.map((p) => (
<li key={p.id}>
<Link
to={`/p/${pondSlug}/${p.slug}`}
className={
p.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'
}
aria-current={p.slug === pageSlug ? 'page' : undefined}
>
{p.title}
</Link>
</li>
))}
</ul>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
)}
{creating ? (
<NewPageForm
pondId={pond.data.id}
pondSlug={pondSlug!}
onCreated={() => {
setCreating(false);
void queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] });
}}
onCancel={() => setCreating(false)}
/>
) : (
<button
type="button"
className="linklike sidebar__new-page"
onClick={() => setCreating(true)}
>
{t('layout.sidebar.newPage')}
</button>
)}
</>
)}
</nav> </nav>
); );
} }

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { PondSwitcher } from './PondSwitcher';
interface TopBarProps { interface TopBarProps {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@ -36,6 +37,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
<Link to="/" className="topbar__brand"> <Link to="/" className="topbar__brand">
Dorfteich Dorfteich
</Link> </Link>
{user && <PondSwitcher />}
<span className="topbar__spacer" /> <span className="topbar__spacer" />
{user ? ( {user ? (
<div className="user-menu"> <div className="user-menu">

View File

@ -0,0 +1,18 @@
import { useLocation } from 'react-router-dom';
interface PondRoute {
pondSlug: string | null;
pageSlug: string | null;
}
/**
* The sidebar and top bar render outside the routed `<Outlet>` (they wrap
* it), so `useParams` cannot see `:pondSlug`/`:pageSlug` those only exist
* on the nested page routes. Parsing the path directly works regardless of
* which route matched underneath.
*/
export function useCurrentPondRoute(): PondRoute {
const { pathname } = useLocation();
const match = /^\/p\/([^/]+)(?:\/([^/]+))?/.exec(pathname);
return { pondSlug: match?.[1] ?? null, pageSlug: match?.[2] ?? null };
}

View File

@ -0,0 +1,46 @@
import type { PageView, PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { FormError } from '../components/forms';
import { apiGet } from '../lib/api';
/**
* Landing route for a pond without a page open yet (`/p/:pondSlug`, e.g.
* from the pond switcher). Redirects to the first page per the pond's sort
* mode once loaded; an empty pond shows a hint pointing at the sidebar's
* "new page" button instead (issue #26).
*/
export function PondHomePage(): React.JSX.Element {
const { t } = useTranslation();
const navigate = useNavigate();
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
useEffect(() => {
if (pages.data && pages.data.length > 0) {
navigate(`/p/${pondSlug}/${pages.data[0]!.slug}`, { replace: true });
}
}, [pages.data, pondSlug, navigate]);
if (pond.error || pages.error) return <FormError error={pond.error ?? pages.error} />;
if (!pond.data || !pages.data || pages.data.length > 0) return <></>;
return (
<div className="pond-home">
<h1>{pond.data.name}</h1>
<p>{t('pondHome.empty')}</p>
</div>
);
}

View File

@ -80,11 +80,14 @@ button {
background: var(--color-bg-subtle); background: var(--color-bg-subtle);
padding: var(--space-4); padding: var(--space-4);
overflow-y: auto; overflow-y: auto;
transition: margin-left 0.15s ease-out; transition:
margin-left 0.18s ease-out,
opacity 0.15s ease-out;
} }
.sidebar--collapsed { .sidebar--collapsed {
margin-left: calc(-1 * var(--sidebar-width)); margin-left: calc(-1 * var(--sidebar-width));
opacity: 0;
} }
.sidebar__hint { .sidebar__hint {
@ -92,6 +95,74 @@ button {
font-size: 0.9rem; font-size: 0.9rem;
} }
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.sidebar__pond-name {
font-size: 1rem;
margin: 0;
}
.sidebar__sort {
font: inherit;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
padding: var(--space-1) var(--space-2);
}
.sidebar__pages {
list-style: none;
margin: 0 0 var(--space-3);
padding: 0;
}
.sidebar__page {
display: block;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
}
.sidebar__page:hover {
background: var(--color-bg);
}
.sidebar__page--active {
background: var(--color-bg);
font-weight: 600;
}
.sidebar__new-page {
font-size: 0.9rem;
}
.sidebar__new-page-form {
margin-top: var(--space-2);
}
.sidebar__new-page-form .field {
margin-bottom: var(--space-2);
}
.sidebar__new-page-actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.pond-home {
max-width: 36rem;
}
.main { .main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@ -4,12 +4,29 @@
"expand": "Seitenleiste einblenden", "expand": "Seitenleiste einblenden",
"collapse": "Seitenleiste ausblenden", "collapse": "Seitenleiste ausblenden",
"label": "Seiten", "label": "Seiten",
"placeholder": "Deine Teiche und Seiten erscheinen hier." "placeholder": "Deine Teiche und Seiten erscheinen hier.",
"empty": "Noch keine Seiten.",
"sortLabel": "Seiten sortieren",
"sortMode": {
"alpha": "AZ",
"created": "Erstellungsdatum"
},
"newPage": "+ Neue Seite",
"newPageTitle": "Titel",
"create": "Erstellen",
"cancel": "Abbrechen"
},
"pondSwitcher": {
"label": "Teich wechseln",
"trigger": "Teich auswählen"
}, },
"user": { "user": {
"anonymous": "Nicht angemeldet" "anonymous": "Nicht angemeldet"
} }
}, },
"pondHome": {
"empty": "Dieser Teich hat noch keine Seiten lege eine über die Seitenleiste an."
},
"home": { "home": {
"title": "Willkommen im Dorfteich", "title": "Willkommen im Dorfteich",
"intro": "Dorfteich ist ein Open-Source-Wiki mit Echtzeit-Zusammenarbeit. Diese Instanz wird gerade eingerichtet.", "intro": "Dorfteich ist ein Open-Source-Wiki mit Echtzeit-Zusammenarbeit. Diese Instanz wird gerade eingerichtet.",

View File

@ -4,12 +4,29 @@
"expand": "Show sidebar", "expand": "Show sidebar",
"collapse": "Hide sidebar", "collapse": "Hide sidebar",
"label": "Pages", "label": "Pages",
"placeholder": "Your ponds and pages will appear here." "placeholder": "Your ponds and pages will appear here.",
"empty": "No pages yet.",
"sortLabel": "Sort pages",
"sortMode": {
"alpha": "AZ",
"created": "Creation date"
},
"newPage": "+ New page",
"newPageTitle": "Title",
"create": "Create",
"cancel": "Cancel"
},
"pondSwitcher": {
"label": "Switch pond",
"trigger": "Select a pond"
}, },
"user": { "user": {
"anonymous": "Not signed in" "anonymous": "Not signed in"
} }
}, },
"pondHome": {
"empty": "This pond doesn't have any pages yet — create one from the sidebar."
},
"home": { "home": {
"title": "Welcome to Dorfteich", "title": "Welcome to Dorfteich",
"intro": "Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.", "intro": "Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.",