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);
|
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,
|
||||||
|
|||||||
@ -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`)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
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 { 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 />}>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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);
|
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;
|
||||||
|
|||||||
@ -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": "A–Z",
|
||||||
|
"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.",
|
||||||
|
|||||||
@ -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": "A–Z",
|
||||||
|
"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.",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user