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
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:
parent
076883a9a6
commit
49beb45b3e
@ -39,6 +39,11 @@ export class PagesController {
|
||||
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')
|
||||
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
|
||||
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
|
||||
* 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')
|
||||
async getStateBySlug(
|
||||
@Param('pondId') pondId: string,
|
||||
|
||||
@ -250,6 +250,70 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
|
||||
.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 () => {
|
||||
const created = await api()
|
||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||
|
||||
@ -11,7 +11,9 @@ import {
|
||||
PageStateView,
|
||||
PageView,
|
||||
SavePageStateInput,
|
||||
SidebarSortMode,
|
||||
UpdatePageInput,
|
||||
pondSettingsSchema,
|
||||
slugify,
|
||||
} from '@dorfteich/shared';
|
||||
import { Page, Prisma, User } from '@prisma/client';
|
||||
@ -104,6 +106,27 @@ export class PagesService {
|
||||
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> {
|
||||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||
this.access.assertCanModify(user, pond);
|
||||
|
||||
147
apps/web/e2e/sidebar.spec.ts
Normal file
147
apps/web/e2e/sidebar.spec.ts
Normal 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();
|
||||
});
|
||||
@ -6,6 +6,7 @@ import { AdminSettingsPage } from './pages/AdminSettingsPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { NotFoundPage } from './pages/NotFoundPage';
|
||||
import { PageEditorPage } from './pages/PageEditorPage';
|
||||
import { PondHomePage } from './pages/PondHomePage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
|
||||
import { LoginPage } from './pages/auth/LoginPage';
|
||||
@ -30,6 +31,7 @@ export function App(): React.JSX.Element {
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="p/:pondSlug" element={<PondHomePage />} />
|
||||
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireSiteAdmin />}>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
@ -11,6 +11,19 @@ export function AppLayout(): React.JSX.Element {
|
||||
const [forcedHidden, setForcedHidden] = useState(false);
|
||||
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 (
|
||||
<SidebarChromeContext.Provider value={setForcedHidden}>
|
||||
<div className="app">
|
||||
|
||||
64
apps/web/src/layout/NewPageForm.tsx
Normal file
64
apps/web/src/layout/NewPageForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/layout/PondSwitcher.tsx
Normal file
54
apps/web/src/layout/PondSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 { 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 {
|
||||
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
|
||||
* text; the collapse behavior and layout contract are final: collapsing
|
||||
* must not reflow the main content beyond reclaiming the width.
|
||||
* Left sidebar: the current pond's page list, sort mode, active-page
|
||||
* highlight, and "new page" flow (issue #26). Collapse behavior and the
|
||||
* layout contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
||||
*/
|
||||
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
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 (
|
||||
<nav
|
||||
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
||||
aria-hidden={collapsed}
|
||||
aria-label={t('layout.sidebar.label')}
|
||||
>
|
||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||
{!pond.data ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { PondSwitcher } from './PondSwitcher';
|
||||
|
||||
interface TopBarProps {
|
||||
sidebarCollapsed: boolean;
|
||||
@ -36,6 +37,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
<Link to="/" className="topbar__brand">
|
||||
Dorfteich
|
||||
</Link>
|
||||
{user && <PondSwitcher />}
|
||||
<span className="topbar__spacer" />
|
||||
{user ? (
|
||||
<div className="user-menu">
|
||||
|
||||
18
apps/web/src/layout/use-pond-route.ts
Normal file
18
apps/web/src/layout/use-pond-route.ts
Normal 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 };
|
||||
}
|
||||
46
apps/web/src/pages/PondHomePage.tsx
Normal file
46
apps/web/src/pages/PondHomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -80,11 +80,14 @@ button {
|
||||
background: var(--color-bg-subtle);
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
transition: margin-left 0.15s ease-out;
|
||||
transition:
|
||||
margin-left 0.18s ease-out,
|
||||
opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
margin-left: calc(-1 * var(--sidebar-width));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar__hint {
|
||||
@ -92,6 +95,74 @@ button {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@ -4,12 +4,29 @@
|
||||
"expand": "Seitenleiste einblenden",
|
||||
"collapse": "Seitenleiste ausblenden",
|
||||
"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": "A–Z",
|
||||
"created": "Erstellungsdatum"
|
||||
},
|
||||
"newPage": "+ Neue Seite",
|
||||
"newPageTitle": "Titel",
|
||||
"create": "Erstellen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"pondSwitcher": {
|
||||
"label": "Teich wechseln",
|
||||
"trigger": "Teich auswählen"
|
||||
},
|
||||
"user": {
|
||||
"anonymous": "Nicht angemeldet"
|
||||
}
|
||||
},
|
||||
"pondHome": {
|
||||
"empty": "Dieser Teich hat noch keine Seiten – lege eine über die Seitenleiste an."
|
||||
},
|
||||
"home": {
|
||||
"title": "Willkommen im Dorfteich",
|
||||
"intro": "Dorfteich ist ein Open-Source-Wiki mit Echtzeit-Zusammenarbeit. Diese Instanz wird gerade eingerichtet.",
|
||||
|
||||
@ -4,12 +4,29 @@
|
||||
"expand": "Show sidebar",
|
||||
"collapse": "Hide sidebar",
|
||||
"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": "A–Z",
|
||||
"created": "Creation date"
|
||||
},
|
||||
"newPage": "+ New page",
|
||||
"newPageTitle": "Title",
|
||||
"create": "Create",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"pondSwitcher": {
|
||||
"label": "Switch pond",
|
||||
"trigger": "Select a pond"
|
||||
},
|
||||
"user": {
|
||||
"anonymous": "Not signed in"
|
||||
}
|
||||
},
|
||||
"pondHome": {
|
||||
"empty": "This pond doesn't have any pages yet — create one from the sidebar."
|
||||
},
|
||||
"home": {
|
||||
"title": "Welcome to Dorfteich",
|
||||
"intro": "Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user