diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index 464728c..f0c044f 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -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 { + return this.pages.list(request.user!, pondId); + } + @Get('pages/:id') async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise { 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, diff --git a/apps/api/src/pages/pages.e2e.db.test.ts b/apps/api/src/pages/pages.e2e.db.test.ts index 4b8c1ad..25e3565 100644 --- a/apps/api/src/pages/pages.e2e.db.test.ts +++ b/apps/api/src/pages/pages.e2e.db.test.ts @@ -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`) diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 359db70..1804818 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -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 = + { + 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 { + 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 { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); this.access.assertCanModify(user, pond); diff --git a/apps/web/e2e/sidebar.spec.ts b/apps/web/e2e/sidebar.spec.ts new file mode 100644 index 0000000..f101a5e --- /dev/null +++ b/apps/web/e2e/sidebar.spec.ts @@ -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>, +): 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>, + 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 { + 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(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 759c94b..feea189 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 { }> } /> + } /> } /> }> diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index 7c51b3f..b99e3bb 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -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 (
diff --git a/apps/web/src/layout/NewPageForm.tsx b/apps/web/src/layout/NewPageForm.tsx new file mode 100644 index 0000000..5df0935 --- /dev/null +++ b/apps/web/src/layout/NewPageForm.tsx @@ -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(null); + const form = useForm({ resolver: zodResolver(createPageInputSchema) }); + + const onSubmit = form.handleSubmit(async (input) => { + setError(null); + try { + const page = await apiPost(`/ponds/${pondId}/pages`, input); + onCreated(); + navigate(`/p/${pondSlug}/${page.slug}`); + } catch (err) { + setError(err); + } + }); + + return ( +
void onSubmit(event)} noValidate> + + + { + if (event.key === 'Escape') onCancel(); + }} + /> + +
+ + +
+ + ); +} diff --git a/apps/web/src/layout/PondSwitcher.tsx b/apps/web/src/layout/PondSwitcher.tsx new file mode 100644 index 0000000..3498582 --- /dev/null +++ b/apps/web/src/layout/PondSwitcher.tsx @@ -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('/ponds'), + enabled: Boolean(user), + }); + + if (!ponds.data || ponds.data.length === 0) return null; + const current = ponds.data.find((pond) => pond.slug === pondSlug); + + return ( +
+ + {open && ( +
+ {ponds.data.map((pond) => ( + setOpen(false)} + > + {pond.name} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 82ae310..4d2db24 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -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(`/ponds/${pondSlug}`), + enabled: pondSlug !== null, + }); + + const pages = useQuery({ + queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], + queryFn: () => apiGet(`/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 { + if (!pond.data) return; + await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + } + return ( ); } diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index b132075..5dd7094 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -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 Dorfteich + {user && } {user ? (
diff --git a/apps/web/src/layout/use-pond-route.ts b/apps/web/src/layout/use-pond-route.ts new file mode 100644 index 0000000..fb3774b --- /dev/null +++ b/apps/web/src/layout/use-pond-route.ts @@ -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 `` (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 }; +} diff --git a/apps/web/src/pages/PondHomePage.tsx b/apps/web/src/pages/PondHomePage.tsx new file mode 100644 index 0000000..9e82e3e --- /dev/null +++ b/apps/web/src/pages/PondHomePage.tsx @@ -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(`/ponds/${pondSlug}`), + }); + const pages = useQuery({ + queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], + queryFn: () => apiGet(`/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 ; + if (!pond.data || !pages.data || pages.data.length > 0) return <>; + + return ( +
+

{pond.data.name}

+

{t('pondHome.empty')}

+
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index ce8150a..ee7d3a0 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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; diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index 57841ce..024bb26 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -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.", diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index c475142..4115343 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -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.",