Polish round 2: content footer, dismissable menus, manual versions, substring search, icon actions in settings (M10 follow-up)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m35s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m44s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 46s

- content footer: the collab status is an icon (wifi/off/refresh, localized
  tooltip + visually-hidden text, class/data-status hooks kept for e2e) on
  the left, the legal links right-aligned; read mode drops the editor
  frame and its inner padding, edit mode keeps it
- menus (page overflow, user, notifications bell, pond switcher) close on
  outside click and Escape via a shared useDismissable hook; the bell got
  its missing tooltip
- side panels (labels, history) stack vertically in one column
- edit mode gains a Save-version icon (prompt for the name, POST
  /pages/:id/versions); the history panel lists contributors by display
  name — more than three collapse to two plus an expandable ellipsis
  (PageVersionView.contributors resolved server-side, deleted users drop
  out)
- search finds partial words via a LIKE fallback next to the tsquery
  (FTS matches still rank first; regression-pinned in the db pack), and
  the recent-searches list has a clear button
- pond owners create labels directly in the label picker (plus a
  permanent link to the full manager); add/remove/delete buttons across
  the pond settings (members, access rules, labels, files) and the
  watch/unwatch toggles in pond/user settings are icon buttons now —
  class hooks and accessible names unchanged for the e2e packs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-12 07:13:34 +02:00
parent 33121cd73d
commit 83fa23bbf9
27 changed files with 488 additions and 67 deletions

View File

@ -103,6 +103,16 @@ export class PostgresSearchProvider extends SearchProvider {
// A phrase that folds to nothing (e.g. only punctuation) matches nothing.
if (normalized.trim() === '') return [];
// Substring fallback (M10 follow-up): the tsquery only matches whole
// words, so a plain LIKE over title/body catches partial words too. The
// pattern keeps the raw (lowercased) input — umlauts etc. match verbatim;
// diacritic-insensitive matching stays the FTS branch's job. FTS hits
// still rank first (ts_rank is 0 for LIKE-only matches).
const likePattern = `%${query.q
.trim()
.toLowerCase()
.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
// Pond-level prefilter (visible ponds only) keeps the LIMIT meaningful;
// the exact per-page resolution happens below (issue #52, ADR 0010).
const visiblePondIds = await this.permissions.visiblePondIds(user);
@ -132,7 +142,9 @@ export class PostgresSearchProvider extends SearchProvider {
JOIN pages p ON p.id = c.page_id AND p.deleted_at IS NULL
JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL,
websearch_to_tsquery('simple', ${normalized}) q
WHERE c.search_vector @@ q
WHERE (c.search_vector @@ q
OR lower(p.title) LIKE ${likePattern}
OR lower(c.plain_text) LIKE ${likePattern})
${visibility}
${scope}
${labelFilter}

View File

@ -102,6 +102,21 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
expect(results.map((r) => r.pageId)).toContain(page);
});
it('matches partial words in title and body (M10 follow-up)', async () => {
const titlePage = await makePage(`Quakfrosch${suffix} Titel`, 'nichts weiter');
const bodyPage = await makePage('anderer Titel', `hier lebt ein Teichmolch${suffix}`);
// A mid-word fragment matches nothing via the tsquery — the LIKE branch
// has to find both pages (title and body).
const byTitle = await search.search({ q: `akfrosch${suffix}` }, owner);
expect(byTitle.map((r) => r.pageId)).toContain(titlePage);
const byBody = await search.search({ q: `eichmolch${suffix}` }, owner);
expect(byBody.map((r) => r.pageId)).toContain(bodyPage);
// The outsider still sees nothing through the substring branch.
expect(await search.search({ q: `eichmolch${suffix}` }, outsider)).toEqual([]);
});
it('never returns pages the requester may not read', async () => {
const results = await search.search({ q: term }, outsider);
expect(results).toEqual([]);

View File

@ -45,7 +45,7 @@ export class VersionsService {
this.logger.setContext(VersionsService.name);
}
viewOf(version: Omit<PageVersion, 'ydocSnapshot'>): PageVersionView {
viewOf(version: Omit<PageVersion, 'ydocSnapshot'>, names: Map<string, string>): PageVersionView {
return {
id: version.id,
pageId: version.pageId,
@ -53,10 +53,26 @@ export class VersionsService {
label: version.label,
createdBy: version.createdBy,
contributorIds: version.contributorIds,
contributors: version.contributorIds
.filter((id) => names.has(id))
.map((id) => ({ id, name: names.get(id)! })),
createdAt: version.createdAt.toISOString(),
};
}
/** Display names for every contributor across `versions` (deleted users drop out). */
private async contributorNames(
versions: { contributorIds: string[] }[],
): Promise<Map<string, string>> {
const ids = [...new Set(versions.flatMap((version) => version.contributorIds))];
if (ids.length === 0) return new Map();
const users = await this.prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, displayName: true },
});
return new Map(users.map((user) => [user.id, user.displayName]));
}
/**
* Load a live page. Viewing history requires the same permission as
* editing (ADR 0013) the guard enforces write access on every history
@ -77,7 +93,8 @@ export class VersionsService {
// Exclude the (potentially large) snapshot bytes from the list.
omit: { ydocSnapshot: true },
});
return versions.map((version) => this.viewOf(version));
const names = await this.contributorNames(versions);
return versions.map((version) => this.viewOf(version, names));
}
/** A single version rendered read-only (HTML) with its Markdown for diffing. */
@ -92,7 +109,8 @@ export class VersionsService {
});
if (!version) throw new NotFoundException();
const derived = deriveContent(new Uint8Array(version.ydocSnapshot));
return { ...this.viewOf(version), html: derived.html, markdown: derived.markdown };
const names = await this.contributorNames([version]);
return { ...this.viewOf(version, names), html: derived.html, markdown: derived.markdown };
}
/**
@ -118,7 +136,7 @@ export class VersionsService {
{ event: 'audit: version restore requested', pageId, versionId, userId: user.id },
'version restore requested',
);
return this.viewOf(version);
return this.viewOf(version, await this.contributorNames([version]));
}
/**
@ -159,7 +177,7 @@ export class VersionsService {
);
// A named snapshot is a meaningful change unit — notify watchers (#94).
await this.notifications.fanoutPageEvent('page_changed', pageId, [user.id]);
return this.viewOf(created);
return this.viewOf(created, await this.contributorNames([created]));
}
/** Reconstruct the page's full current Yjs state (base + update log). */

View File

@ -8,6 +8,7 @@ import type {
import { isRuleShadowed } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import type { TFunction } from 'i18next';
import { Plus, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -237,8 +238,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{error}
</p>
)}
<button className="button rule-add__submit" type="submit" disabled={!canSubmit}>
{t('add.submit')}
<button
className="icon-button rule-add__submit"
type="submit"
disabled={!canSubmit}
aria-label={t('add.submit')}
title={t('add.submit')}
>
<Plus aria-hidden />
</button>
</form>
@ -255,10 +262,12 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
<button
type="button"
className="button rule-remove"
className="icon-button rule-remove"
aria-label={t('remove')}
title={t('remove')}
onClick={() => void mutations.remove(rule.id)}
>
{t('remove')}
<Trash2 aria-hidden />
</button>
</li>
))}

View File

@ -6,6 +6,48 @@ import { useTranslation } from 'react-i18next';
import { apiGet, apiGetText, apiPost } from '../lib/api';
/** How many contributor names show before the list collapses behind "…". */
const CONTRIBUTORS_SHOWN_COLLAPSED = 2;
/**
* Contributor names for a version (M10 follow-up): up to three names show in
* full; longer lists collapse to the first two plus a "…" button that
* expands the rest.
*/
function ContributorNames({
contributors,
}: {
contributors: { id: string; name: string }[];
}): React.JSX.Element | null {
const { t } = useTranslation('editor');
const [expanded, setExpanded] = useState(false);
if (contributors.length === 0) return null;
const names = contributors.map((contributor) => contributor.name);
const collapsed = names.length > 3 && !expanded;
const shown = collapsed ? names.slice(0, CONTRIBUTORS_SHOWN_COLLAPSED) : names;
return (
<span className="history-panel__contributors">
{shown.join(', ')}
{collapsed && (
<>
{', '}
<button
type="button"
className="linklike history-panel__contributors-more"
aria-label={t('history.showAllContributors')}
title={t('history.showAllContributors')}
onClick={() => setExpanded(true)}
>
</button>
</>
)}
</span>
);
}
/**
* Version history panel (issue #42, ADR 0013): lists the page's versions and,
* for a selected one, shows a read-only render and a Markdown diff against the
@ -87,10 +129,9 @@ export function HistoryPanel({
</span>
<span className="history-panel__meta">
{version.label ?? t(`history.trigger.${version.trigger}`)}
{version.contributorIds.length > 0 &&
` · ${t('history.contributors', { count: version.contributorIds.length })}`}
</span>
</button>
<ContributorNames contributors={version.contributors} />
</li>
))}
</ol>

View File

@ -1,5 +1,6 @@
import type { PondFilesView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -70,10 +71,12 @@ export function PondFileManager({ pondId }: { pondId: string }): React.JSX.Eleme
</span>
<button
type="button"
className="button attachments-item__delete"
className="icon-button attachments-item__delete"
aria-label={t('delete')}
title={t('delete')}
onClick={() => void remove(item.id)}
>
{t('delete')}
<Trash2 aria-hidden />
</button>
</li>
))}

View File

@ -1,5 +1,6 @@
import type { LabelTreeNode, LabelView } from '@dorfteich/shared';
import { collectSubtreeIds } from '@dorfteich/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -71,8 +72,14 @@ export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element
placeholder={t('settings.newRootPlaceholder')}
aria-label={t('settings.newRootPlaceholder')}
/>
<button type="submit" className="button" disabled={!newRoot.trim()}>
{t('settings.add')}
<button
type="submit"
className="icon-button"
disabled={!newRoot.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<Plus aria-hidden />
</button>
</form>
@ -245,10 +252,12 @@ function LabelNode({
</button>
<button
type="button"
className="linklike label-node__delete"
className="icon-button label-node__delete"
aria-label={t('settings.delete')}
title={t('settings.delete')}
onClick={() => void remove()}
>
{t('settings.delete')}
<Trash2 aria-hidden />
</button>
</div>
</div>
@ -269,8 +278,14 @@ function LabelNode({
aria-label={t('settings.addChild')}
onChange={(event) => setChildName(event.target.value)}
/>
<button type="submit" className="button" disabled={!childName.trim()}>
{t('settings.add')}
<button
type="submit"
className="icon-button"
disabled={!childName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<Plus aria-hidden />
</button>
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
{t('settings.cancel')}

View File

@ -1,18 +1,22 @@
import type { LabelView } from '@dorfteich/shared';
import type { LabelView, PondView } from '@dorfteich/shared';
import { labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { apiDelete, apiGet, apiPost } from '../lib/api';
import { usePondLabels } from './use-pond-labels';
import { useAuth } from '../auth/auth-context';
import { ApiError, apiDelete, apiGet, apiPost } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
/**
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
* the pond's labels. Toggling a label assigns/unassigns it immediately and
* refreshes both the page's labels and the sidebar (chips + filter). Shown only
* to users who may edit the page; the api enforces the permission.
* to users who may edit the page; the api enforces the permission. Pond owners
* can create labels right here and jump to the full management in the pond
* settings (M10 follow-up).
*/
export function LabelPicker({
pageId,
@ -26,10 +30,37 @@ export function LabelPicker({
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('labels');
const { t: tErrors } = useTranslation('errors');
const { user } = useAuth();
const queryClient = useQueryClient();
const { flat, isLoading } = usePondLabels(pondId);
const mutations = useLabelMutations(pondId);
const [search, setSearch] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const mayManage = Boolean(user && pond.data && user.id === pond.data.ownerId);
async function createLabel(): Promise<void> {
const name = newName.trim();
if (!name) return;
setCreateError(null);
try {
await mutations.create(name, null);
setNewName('');
} catch (err) {
setCreateError(
err instanceof ApiError
? tErrors(err.body.code, { defaultValue: err.body.message, ...(err.body.details ?? {}) })
: tErrors('internal_error'),
);
}
}
const assigned = useQuery({
queryKey: ['page-labels', pageId],
@ -65,10 +96,12 @@ export function LabelPicker({
</button>
</header>
{isLoading ? null : flat.length === 0 ? (
{isLoading ? null : flat.length === 0 && !mayManage ? (
<p className="label-picker__empty">
{t('picker.empty')} <Link to={`/p/${pondSlug}/settings`}>{t('picker.manageHint')}</Link>
</p>
) : flat.length === 0 ? (
<p className="label-picker__empty">{t('picker.empty')}</p>
) : (
<>
<input
@ -109,6 +142,43 @@ export function LabelPicker({
)}
</>
)}
{/* Owners manage labels right here: quick create + the full manager
in the pond settings (M10 follow-up). */}
{mayManage && (
<footer className="label-picker__manage">
<form
className="label-picker__create"
onSubmit={(event) => {
event.preventDefault();
void createLabel();
}}
>
<input
type="text"
value={newName}
placeholder={t('settings.newRootPlaceholder')}
aria-label={t('settings.newRootPlaceholder')}
onChange={(event) => setNewName(event.target.value)}
/>
<button
type="submit"
className="icon-button label-picker__create-submit"
disabled={!newName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<Plus aria-hidden />
</button>
</form>
{createError && (
<p className="label-picker__error" role="alert">
{createError}
</p>
)}
<Link to={`/p/${pondSlug}/settings`}>{t('picker.manageHint')}</Link>
</footer>
)}
</aside>
);
}

View File

@ -23,14 +23,17 @@ export function AppLayout(): React.JSX.Element {
// portals its icon actions (#101) and presence strip (#102) into them.
const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null);
const [presenceElement, setPresenceElement] = useState<HTMLElement | null>(null);
const [statusElement, setStatusElement] = useState<HTMLElement | null>(null);
const actionsSlot = useMemo(
() => ({
element: actionsElement,
setElement: setActionsElement,
presenceElement,
setPresenceElement,
statusElement,
setStatusElement,
}),
[actionsElement, presenceElement],
[actionsElement, presenceElement, statusElement],
);
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of

View File

@ -1,15 +1,21 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { usePageActionsSlot } from './page-actions';
/**
* The app-wide footer (issue #82): legal links on every view editor,
* public pages, and auth screens all render inside AppLayout, so this one
* spot covers them all.
* spot covers them all. Since the M10 follow-ups the active page portals
* its connection-status icon into the left half; the legal links sit right.
*/
export function Footer(): React.JSX.Element {
const { t } = useTranslation('legal');
const { setStatusElement } = usePageActionsSlot();
return (
<footer className="app-footer">
<div className="app-footer__status" ref={setStatusElement} />
<span className="app-footer__spacer" />
<Link to="/legal/imprint">{t('links.imprint')}</Link>
<span aria-hidden>·</span>
<Link to="/legal/privacy">{t('links.privacy')}</Link>

View File

@ -1,11 +1,12 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useRef, 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 { useDismissable } from '../lib/use-dismissable';
import { useCurrentPondRoute } from './use-pond-route';
/** Top-bar dropdown to switch between the signed-in user's ponds (issue #26). */
@ -14,6 +15,8 @@ export function PondSwitcher(): React.JSX.Element | null {
const { user } = useAuth();
const { pondSlug } = useCurrentPondRoute();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useDismissable(menuRef, open, () => setOpen(false));
const ponds = useQuery({
queryKey: ['ponds'],
queryFn: () => apiGet<PondView[]>('/ponds'),
@ -24,7 +27,7 @@ export function PondSwitcher(): React.JSX.Element | null {
const current = ponds.data.find((pond) => pond.slug === pondSlug);
return (
<div className="user-menu">
<div className="user-menu" ref={menuRef}>
<button
type="button"
className="user-menu__trigger"

View File

@ -1,12 +1,13 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { Menu, Search, Settings } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { SearchPalette } from '../search/SearchPalette';
import { NotificationsBell } from '../notifications/NotificationsBell';
import { usePageActionsSlot } from './page-actions';
@ -31,6 +32,8 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
useDismissable(userMenuRef, menuOpen, () => setMenuOpen(false));
const { setElement: setPageActionsElement, setPresenceElement } = usePageActionsSlot();
// Pond-settings shortcut next to the pond name (M10 follow-up): owners get
// a gear icon while a pond route is active. Shares the sidebar's query key.
@ -105,7 +108,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
{user && <NotificationsBell />}
{user ? (
<div className="user-menu">
<div className="user-menu" ref={userMenuRef}>
<button
type="button"
className="user-menu__trigger"

View File

@ -14,6 +14,9 @@ export interface PageActionsSlot {
setElement: (element: HTMLElement | null) => void;
presenceElement: HTMLElement | null;
setPresenceElement: (element: HTMLElement | null) => void;
/** Left half of the content footer: the page's connection status icon. */
statusElement: HTMLElement | null;
setStatusElement: (element: HTMLElement | null) => void;
}
export const PageActionsSlotContext = createContext<PageActionsSlot>({
@ -21,6 +24,8 @@ export const PageActionsSlotContext = createContext<PageActionsSlot>({
setElement: () => {},
presenceElement: null,
setPresenceElement: () => {},
statusElement: null,
setStatusElement: () => {},
});
export function usePageActionsSlot(): PageActionsSlot {

View File

@ -0,0 +1,28 @@
import { useEffect, type RefObject } from 'react';
/**
* Closes a dropdown/menu when the user clicks anywhere outside `ref` or
* presses Escape (M10 follow-up). Pass the menu's *container* (trigger +
* popup) so clicks on the trigger keep working as a toggle.
*/
export function useDismissable(
ref: RefObject<HTMLElement | null>,
open: boolean,
onClose: () => void,
): void {
useEffect(() => {
if (!open) return undefined;
function onPointerDown(event: PointerEvent): void {
if (ref.current && !ref.current.contains(event.target as Node)) onClose();
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') onClose();
}
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [ref, open, onClose]);
}

View File

@ -1,4 +1,5 @@
import type { MemberRole, MemberView, SeatedMemberRole } from '@dorfteich/shared';
import { UserMinus, UserPlus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -139,8 +140,14 @@ export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element
))}
</select>
</label>
<button className="button member-add__submit" type="submit" disabled={addBlockedByQuota}>
{t('add.submit')}
<button
className="icon-button member-add__submit"
type="submit"
disabled={addBlockedByQuota}
aria-label={t('add.submit')}
title={t('add.submit')}
>
<UserPlus aria-hidden />
</button>
{addBlockedByQuota && (
<p className="member-add__quota-full" role="note">
@ -231,8 +238,14 @@ function MemberRow({
))}
</select>
</label>
<button type="button" className="button member-row__remove" onClick={onRemove}>
{t('actions.remove')}
<button
type="button"
className="icon-button member-row__remove"
aria-label={t('actions.remove')}
title={t('actions.remove')}
onClick={onRemove}
>
<UserMinus aria-hidden />
</button>
</span>
) : (

View File

@ -1,11 +1,12 @@
import type { NotificationListView, NotificationView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Bell } from 'lucide-react';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { apiGet, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
/**
* The in-app notification center (issue #94): a bell with an unread badge,
@ -18,6 +19,8 @@ export function NotificationsBell(): React.JSX.Element {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const bellRef = useRef<HTMLDivElement>(null);
useDismissable(bellRef, open, () => setOpen(false));
const list = useQuery({
queryKey: ['notifications'],
@ -44,13 +47,14 @@ export function NotificationsBell(): React.JSX.Element {
const unread = list.data?.unreadCount ?? 0;
return (
<div className="notifications-bell">
<div className="notifications-bell" ref={bellRef}>
<button
type="button"
className="notifications-bell__button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('title')}
title={t('title')}
onClick={() => setOpen((value) => !value)}
>
<Bell aria-hidden />

View File

@ -1,4 +1,5 @@
import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared';
import { useQueryClient } from '@tanstack/react-query';
import {
BookOpen,
Copy,
@ -8,17 +9,19 @@ import {
MessageSquare,
Paperclip,
Pencil,
Save,
Tag,
Trash2,
Wrench,
} from 'lucide-react';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton';
import { useDocumentExport } from '../export/use-document-export';
import { apiDelete, apiGetText } from '../lib/api';
import { apiDelete, apiGetText, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { WatchToggle } from '../watches/WatchToggle';
interface PageActionsProps {
@ -58,6 +61,7 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
>
{props.mode === 'edit' ? <BookOpen aria-hidden /> : <Pencil aria-hidden />}
</IconButton>
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
<IconButton
className="editor-shell__comments-toggle"
@ -115,6 +119,40 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
);
}
/** Manually snapshot the page as a named version (M10 follow-up; edit mode
* only). The name comes from a prompt consistent with the confirm()-level
* dialogs used for delete/restore. */
function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
const { t } = useTranslation('editor');
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
async function save(): Promise<void> {
const label = window.prompt(t('history.savePrompt'))?.trim();
if (!label) return;
setBusy(true);
try {
await apiPost(`/pages/${pageId}/versions`, { label: label.slice(0, 100) });
await queryClient.invalidateQueries({ queryKey: ['versions', pageId] });
} catch {
window.alert(t('history.saveFailed'));
} finally {
setBusy(false);
}
}
return (
<IconButton
className="editor-page__save-version"
label={t('history.saveVersion')}
disabled={busy}
onClick={() => void save()}
>
<Save aria-hidden />
</IconButton>
);
}
/** Overflow "" menu: Markdown copy/download (#30), office/PDF export
* (#65/#67), and the destructive move-to-trash (#31, keeps its confirm). */
function PageOverflowMenu({
@ -131,6 +169,8 @@ function PageOverflowMenu({
const [open, setOpen] = useState(false);
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
const { status, exportPage } = useDocumentExport();
const menuRef = useRef<HTMLDivElement>(null);
useDismissable(menuRef, open, () => setOpen(false));
async function copyMarkdown(): Promise<void> {
try {
@ -156,7 +196,7 @@ function PageOverflowMenu({
};
return (
<div className="page-actions__more">
<div className="page-actions__more" ref={menuRef}>
<IconButton
label={t('page.moreActions')}
active={open}

View File

@ -3,6 +3,7 @@ import type { PageListItemView, PageStateView, PondView } from '@dorfteich/share
import { useQuery } from '@tanstack/react-query';
import { Collaboration } from '@tiptap/extension-collaboration';
import { EditorContent, useEditor } from '@tiptap/react';
import { RefreshCw, Wifi, WifiOff } from 'lucide-react';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@ -50,6 +51,13 @@ const DEFAULT_POND_FONTS = {
type Mode = 'view' | 'edit';
/** Footer status glyph per collab connection state (M10 follow-up). */
function ConnectionIcon({ status }: { status: string }): React.JSX.Element {
if (status === 'connected') return <Wifi aria-hidden />;
if (status === 'offline') return <WifiOff aria-hidden />;
return <RefreshCw aria-hidden />;
}
/** The minimal page identity the editor needs available from the API online
* or from the offline page cache after a reload without a connection (#38). */
interface ResolvedPage {
@ -83,7 +91,7 @@ function PageEditor({
const { t } = useTranslation('editor');
const { user } = useAuth();
const navigate = useNavigate();
const { presenceElement } = usePageActionsSlot();
const { presenceElement, statusElement } = usePageActionsSlot();
// Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
@ -192,7 +200,7 @@ function PageEditor({
return (
<WikilinkContext.Provider value={wikilinks}>
<PluginBlockContext.Provider value={pluginBlockScope}>
<div className="editor-shell">
<div className={mode === 'view' ? 'editor-shell editor-shell--reading' : 'editor-shell'}>
<SectionStyleSheets plugins={pondPlugins.data} />
{canEdit && (
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
@ -211,9 +219,22 @@ function PageEditor({
{showComments && (
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
)}
<div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)}
</div>
{/* The connection status renders as an icon in the content footer
(left half); the localized text stays for screen readers and as
the hover tooltip. */}
{statusElement &&
createPortal(
<div
className="editor-connection"
role="status"
data-status={collab.status}
title={t(`connection.${collab.status}`)}
>
<ConnectionIcon status={collab.status} />
<span className="visually-hidden">{t(`connection.${collab.status}`)}</span>
</div>,
statusElement,
)}
{/* Live presence renders in the TopBar next to the page actions
(#102). The slot only exists for signed-in users, and the
public read view never mounts this editor (or any awareness
@ -389,6 +410,9 @@ export function PageEditorPage(): React.JSX.Element {
onCloseAttachments={() => setShowAttachments(false)}
onCloseComments={() => setShowComments(false)}
/>
{/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && (
<div className="editor-page__panels">
{showLabels && (
<LabelPicker
pageId={resolved.id}
@ -401,6 +425,8 @@ export function PageEditorPage(): React.JSX.Element {
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
)}
</div>
)}
</div>
{/* "Linked from" appears below the content in read mode (issue #48). */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
</div>

View File

@ -51,7 +51,7 @@ export function PondSettingsPage(): React.JSX.Element {
<div className="pond-settings-page">
<div className="pond-settings-page__header">
<h1>{pond.data.name}</h1>
<WatchToggle targetType="pond" targetId={pond.data.id} />
<WatchToggle targetType="pond" targetId={pond.data.id} variant="icon" />
</div>
<section>
<h2>{tMembers('title')}</h2>

View File

@ -162,7 +162,23 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
{debounced.length === 0 ? (
recent.length > 0 ? (
<div className="search-palette__recent">
<p className="search-palette__hint">{t('recent')}</p>
<p className="search-palette__hint">
{t('recent')}
<button
type="button"
className="linklike search-palette__recent-clear"
onClick={() => {
try {
localStorage.removeItem(RECENT_KEY);
} catch {
/* ignore availability errors */
}
setRecent([]);
}}
>
{t('recentClear')}
</button>
</p>
<ul>
{recent.map((q) => (
<li key={q}>

View File

@ -495,6 +495,7 @@ button {
/* Legal pages + footer (issue #82) */
.app-footer {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-8);
padding-top: var(--space-3);
@ -503,6 +504,15 @@ button {
font-size: 0.85rem;
}
.app-footer__spacer {
flex: 1;
}
.app-footer__status {
display: flex;
align-items: center;
}
.legal-page {
max-width: 44rem;
}
@ -804,6 +814,17 @@ button {
border-radius: var(--radius);
}
/* Read mode drops the frame and its inner padding the rendered page sits
directly in the content area (M10 follow-up). */
.editor-shell--reading {
border: none;
border-radius: 0;
}
.editor-shell--reading > .editor-content {
padding: 0;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
@ -992,12 +1013,18 @@ button {
gap: var(--space-1);
}
/* The connection-status icon in the content footer (left half). */
.editor-connection {
padding: var(--space-1) var(--space-3);
font-size: 0.85rem;
display: inline-flex;
align-items: center;
color: var(--color-text-muted);
}
.editor-connection svg {
width: 1rem;
height: 1rem;
}
.editor-connection[data-status='connected'] {
color: var(--color-accent);
}
@ -1106,6 +1133,22 @@ button {
min-width: 0;
}
/* Column holding the open side panels next to the editor; each panel takes
the column's full width and they stack vertically (M10 follow-up). */
.editor-page__panels {
display: flex;
flex-direction: column;
gap: var(--space-4);
flex: 0 0 22rem;
max-width: 22rem;
}
.editor-page__panels > .label-picker,
.editor-page__panels > .history-panel {
flex: none;
max-width: none;
}
.history-panel {
flex: 0 0 22rem;
max-width: 22rem;
@ -1157,6 +1200,43 @@ button {
background: var(--color-bg-subtle);
}
/* Owner-facing quick label management inside the picker (M10 follow-up). */
.label-picker__manage {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
font-size: 0.85rem;
}
.label-picker__create {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.label-picker__create input {
flex: 1;
min-width: 0;
}
.label-picker__error {
color: var(--color-danger);
font-size: 0.85rem;
}
/* Contributor names under each version entry (M10 follow-up). */
.history-panel__contributors {
display: block;
padding: 0 var(--space-2) var(--space-1);
font-size: 0.8rem;
color: var(--color-text-muted);
}
.history-panel__contributors-more {
font: inherit;
}
.history-panel__when {
font-weight: 600;
font-size: 0.9rem;

View File

@ -1,5 +1,6 @@
import type { WatchListView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@ -43,10 +44,12 @@ export function WatchesSection(): React.JSX.Element {
<span className="watches-list__type">{t(`settings.types.${watch.targetType}`)}</span>
<button
type="button"
className="button"
className="icon-button watches-list__unwatch"
aria-label={t('settings.unwatch')}
title={t('settings.unwatch')}
onClick={() => void unwatch(watch.targetType, watch.targetId)}
>
{t('settings.unwatch')}
<EyeOff aria-hidden />
</button>
</li>
))}

View File

@ -109,13 +109,15 @@
"restoreConfirm": "Die Seite auf diese Version zurücksetzen? Der aktuelle Stand wird vorher als Version gesichert, sodass du das rückgängig machen kannst.",
"diffTitle": "Änderungen von dieser Version bis heute",
"viewTitle": "Vorschau",
"contributors_one": "{{count}} Beitragende:r",
"contributors_other": "{{count}} Beitragende",
"trigger": {
"auto": "Automatischer Schnappschuss",
"manual": "Benannte Version",
"pre_restore": "Vor einer Wiederherstellung"
}
},
"saveVersion": "Version speichern",
"savePrompt": "Name der Version:",
"saveFailed": "Version konnte nicht gespeichert werden.",
"showAllContributors": "Alle Mitwirkenden anzeigen"
},
"page": {
"copyMarkdown": "Als Markdown kopieren",

View File

@ -11,6 +11,7 @@
"hint": "Tippe, um deine Seiten zu durchsuchen.",
"error": "Suche fehlgeschlagen. Bitte erneut versuchen.",
"recent": "Letzte Suchen",
"recentClear": "Liste löschen",
"resultsLabel": "Suchergebnisse",
"inPond": "in {{pond}}",
"close": "Schließen"

View File

@ -109,13 +109,15 @@
"restoreConfirm": "Restore the page to this version? The current state is saved as a version first, so you can undo this.",
"diffTitle": "Changes from this version to now",
"viewTitle": "Preview",
"contributors_one": "{{count}} contributor",
"contributors_other": "{{count}} contributors",
"trigger": {
"auto": "Automatic snapshot",
"manual": "Named version",
"pre_restore": "Before a restore"
}
},
"saveVersion": "Save version",
"savePrompt": "Version name:",
"saveFailed": "The version could not be saved.",
"showAllContributors": "Show all contributors"
},
"page": {
"copyMarkdown": "Copy as Markdown",

View File

@ -11,6 +11,7 @@
"hint": "Type to search your pages.",
"error": "Search failed. Please try again.",
"recent": "Recent searches",
"recentClear": "Clear list",
"resultsLabel": "Search results",
"inPond": "in {{pond}}",
"close": "Close"

View File

@ -98,6 +98,8 @@ export interface PageVersionView {
createdBy: string | null;
/** Users who edited since the previous version. */
contributorIds: string[];
/** Display names for contributorIds; deleted accounts are omitted. */
contributors: { id: string; name: string }[];
createdAt: string;
}