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
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:
parent
33121cd73d
commit
83fa23bbf9
@ -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}
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
28
apps/web/src/lib/use-dismissable.ts
Normal file
28
apps/web/src/lib/use-dismissable.ts
Normal 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]);
|
||||
}
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,16 +410,21 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
onCloseAttachments={() => setShowAttachments(false)}
|
||||
onCloseComments={() => setShowComments(false)}
|
||||
/>
|
||||
{showLabels && (
|
||||
<LabelPicker
|
||||
pageId={resolved.id}
|
||||
pondId={resolved.pondId}
|
||||
pondSlug={pondSlug}
|
||||
onClose={() => setShowLabels(false)}
|
||||
/>
|
||||
)}
|
||||
{showHistory && (
|
||||
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
||||
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||
{(showLabels || showHistory) && (
|
||||
<div className="editor-page__panels">
|
||||
{showLabels && (
|
||||
<LabelPicker
|
||||
pageId={resolved.id}
|
||||
pondId={resolved.pondId}
|
||||
pondSlug={pondSlug}
|
||||
onClose={() => setShowLabels(false)}
|
||||
/>
|
||||
)}
|
||||
{showHistory && (
|
||||
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* "Linked from" appears below the content in read mode (issue #48). */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user