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.
|
// A phrase that folds to nothing (e.g. only punctuation) matches nothing.
|
||||||
if (normalized.trim() === '') return [];
|
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;
|
// Pond-level prefilter (visible ponds only) keeps the LIMIT meaningful;
|
||||||
// the exact per-page resolution happens below (issue #52, ADR 0010).
|
// the exact per-page resolution happens below (issue #52, ADR 0010).
|
||||||
const visiblePondIds = await this.permissions.visiblePondIds(user);
|
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 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,
|
JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL,
|
||||||
websearch_to_tsquery('simple', ${normalized}) q
|
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}
|
${visibility}
|
||||||
${scope}
|
${scope}
|
||||||
${labelFilter}
|
${labelFilter}
|
||||||
|
|||||||
@ -102,6 +102,21 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
|||||||
expect(results.map((r) => r.pageId)).toContain(page);
|
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 () => {
|
it('never returns pages the requester may not read', async () => {
|
||||||
const results = await search.search({ q: term }, outsider);
|
const results = await search.search({ q: term }, outsider);
|
||||||
expect(results).toEqual([]);
|
expect(results).toEqual([]);
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export class VersionsService {
|
|||||||
this.logger.setContext(VersionsService.name);
|
this.logger.setContext(VersionsService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewOf(version: Omit<PageVersion, 'ydocSnapshot'>): PageVersionView {
|
viewOf(version: Omit<PageVersion, 'ydocSnapshot'>, names: Map<string, string>): PageVersionView {
|
||||||
return {
|
return {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
pageId: version.pageId,
|
pageId: version.pageId,
|
||||||
@ -53,10 +53,26 @@ export class VersionsService {
|
|||||||
label: version.label,
|
label: version.label,
|
||||||
createdBy: version.createdBy,
|
createdBy: version.createdBy,
|
||||||
contributorIds: version.contributorIds,
|
contributorIds: version.contributorIds,
|
||||||
|
contributors: version.contributorIds
|
||||||
|
.filter((id) => names.has(id))
|
||||||
|
.map((id) => ({ id, name: names.get(id)! })),
|
||||||
createdAt: version.createdAt.toISOString(),
|
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
|
* Load a live page. Viewing history requires the same permission as
|
||||||
* editing (ADR 0013) — the guard enforces write access on every history
|
* 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.
|
// Exclude the (potentially large) snapshot bytes from the list.
|
||||||
omit: { ydocSnapshot: true },
|
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. */
|
/** A single version rendered read-only (HTML) with its Markdown for diffing. */
|
||||||
@ -92,7 +109,8 @@ export class VersionsService {
|
|||||||
});
|
});
|
||||||
if (!version) throw new NotFoundException();
|
if (!version) throw new NotFoundException();
|
||||||
const derived = deriveContent(new Uint8Array(version.ydocSnapshot));
|
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 },
|
{ event: 'audit: version restore requested', pageId, versionId, userId: user.id },
|
||||||
'version restore requested',
|
'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).
|
// A named snapshot is a meaningful change unit — notify watchers (#94).
|
||||||
await this.notifications.fanoutPageEvent('page_changed', pageId, [user.id]);
|
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). */
|
/** Reconstruct the page's full current Yjs state (base + update log). */
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
import { isRuleShadowed } from '@dorfteich/shared';
|
import { isRuleShadowed } from '@dorfteich/shared';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@ -237,8 +238,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button className="button rule-add__submit" type="submit" disabled={!canSubmit}>
|
<button
|
||||||
{t('add.submit')}
|
className="icon-button rule-add__submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
aria-label={t('add.submit')}
|
||||||
|
title={t('add.submit')}
|
||||||
|
>
|
||||||
|
<Plus aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -255,10 +262,12 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
|
|||||||
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
|
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => void mutations.remove(rule.id)}
|
||||||
>
|
>
|
||||||
{t('remove')}
|
<Trash2 aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -6,6 +6,48 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { apiGet, apiGetText, apiPost } from '../lib/api';
|
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,
|
* 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
|
* for a selected one, shows a read-only render and a Markdown diff against the
|
||||||
@ -87,10 +129,9 @@ export function HistoryPanel({
|
|||||||
</span>
|
</span>
|
||||||
<span className="history-panel__meta">
|
<span className="history-panel__meta">
|
||||||
{version.label ?? t(`history.trigger.${version.trigger}`)}
|
{version.label ?? t(`history.trigger.${version.trigger}`)}
|
||||||
{version.contributorIds.length > 0 &&
|
|
||||||
` · ${t('history.contributors', { count: version.contributorIds.length })}`}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<ContributorNames contributors={version.contributors} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { PondFilesView } from '@dorfteich/shared';
|
import type { PondFilesView } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@ -70,10 +71,12 @@ export function PondFileManager({ pondId }: { pondId: string }): React.JSX.Eleme
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => void remove(item.id)}
|
||||||
>
|
>
|
||||||
{t('delete')}
|
<Trash2 aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { LabelTreeNode, LabelView } from '@dorfteich/shared';
|
import type { LabelTreeNode, LabelView } from '@dorfteich/shared';
|
||||||
import { collectSubtreeIds } from '@dorfteich/shared';
|
import { collectSubtreeIds } from '@dorfteich/shared';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@ -71,8 +72,14 @@ export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element
|
|||||||
placeholder={t('settings.newRootPlaceholder')}
|
placeholder={t('settings.newRootPlaceholder')}
|
||||||
aria-label={t('settings.newRootPlaceholder')}
|
aria-label={t('settings.newRootPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="button" disabled={!newRoot.trim()}>
|
<button
|
||||||
{t('settings.add')}
|
type="submit"
|
||||||
|
className="icon-button"
|
||||||
|
disabled={!newRoot.trim()}
|
||||||
|
aria-label={t('settings.add')}
|
||||||
|
title={t('settings.add')}
|
||||||
|
>
|
||||||
|
<Plus aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -245,10 +252,12 @@ function LabelNode({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onClick={() => void remove()}
|
||||||
>
|
>
|
||||||
{t('settings.delete')}
|
<Trash2 aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -269,8 +278,14 @@ function LabelNode({
|
|||||||
aria-label={t('settings.addChild')}
|
aria-label={t('settings.addChild')}
|
||||||
onChange={(event) => setChildName(event.target.value)}
|
onChange={(event) => setChildName(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="button" disabled={!childName.trim()}>
|
<button
|
||||||
{t('settings.add')}
|
type="submit"
|
||||||
|
className="icon-button"
|
||||||
|
disabled={!childName.trim()}
|
||||||
|
aria-label={t('settings.add')}
|
||||||
|
title={t('settings.add')}
|
||||||
|
>
|
||||||
|
<Plus aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
|
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
|
||||||
{t('settings.cancel')}
|
{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 { labelDepth } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { apiDelete, apiGet, apiPost } from '../lib/api';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { usePondLabels } from './use-pond-labels';
|
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
|
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
|
||||||
* the pond's labels. Toggling a label assigns/unassigns it immediately and
|
* 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
|
* 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({
|
export function LabelPicker({
|
||||||
pageId,
|
pageId,
|
||||||
@ -26,10 +30,37 @@ export function LabelPicker({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('labels');
|
const { t } = useTranslation('labels');
|
||||||
|
const { t: tErrors } = useTranslation('errors');
|
||||||
|
const { user } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { flat, isLoading } = usePondLabels(pondId);
|
const { flat, isLoading } = usePondLabels(pondId);
|
||||||
|
const mutations = useLabelMutations(pondId);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
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({
|
const assigned = useQuery({
|
||||||
queryKey: ['page-labels', pageId],
|
queryKey: ['page-labels', pageId],
|
||||||
@ -65,10 +96,12 @@ export function LabelPicker({
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isLoading ? null : flat.length === 0 ? (
|
{isLoading ? null : flat.length === 0 && !mayManage ? (
|
||||||
<p className="label-picker__empty">
|
<p className="label-picker__empty">
|
||||||
{t('picker.empty')} <Link to={`/p/${pondSlug}/settings`}>{t('picker.manageHint')}</Link>
|
{t('picker.empty')} <Link to={`/p/${pondSlug}/settings`}>{t('picker.manageHint')}</Link>
|
||||||
</p>
|
</p>
|
||||||
|
) : flat.length === 0 ? (
|
||||||
|
<p className="label-picker__empty">{t('picker.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<input
|
<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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,14 +23,17 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
// portals its icon actions (#101) and presence strip (#102) into them.
|
// portals its icon actions (#101) and presence strip (#102) into them.
|
||||||
const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null);
|
const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null);
|
||||||
const [presenceElement, setPresenceElement] = useState<HTMLElement | null>(null);
|
const [presenceElement, setPresenceElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [statusElement, setStatusElement] = useState<HTMLElement | null>(null);
|
||||||
const actionsSlot = useMemo(
|
const actionsSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
element: actionsElement,
|
element: actionsElement,
|
||||||
setElement: setActionsElement,
|
setElement: setActionsElement,
|
||||||
presenceElement,
|
presenceElement,
|
||||||
setPresenceElement,
|
setPresenceElement,
|
||||||
|
statusElement,
|
||||||
|
setStatusElement,
|
||||||
}),
|
}),
|
||||||
[actionsElement, presenceElement],
|
[actionsElement, presenceElement, statusElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
|
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
|
||||||
|
|||||||
@ -1,15 +1,21 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { usePageActionsSlot } from './page-actions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app-wide footer (issue #82): legal links on every view — editor,
|
* The app-wide footer (issue #82): legal links on every view — editor,
|
||||||
* public pages, and auth screens all render inside AppLayout, so this one
|
* 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 {
|
export function Footer(): React.JSX.Element {
|
||||||
const { t } = useTranslation('legal');
|
const { t } = useTranslation('legal');
|
||||||
|
const { setStatusElement } = usePageActionsSlot();
|
||||||
return (
|
return (
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
|
<div className="app-footer__status" ref={setStatusElement} />
|
||||||
|
<span className="app-footer__spacer" />
|
||||||
<Link to="/legal/imprint">{t('links.imprint')}</Link>
|
<Link to="/legal/imprint">{t('links.imprint')}</Link>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<Link to="/legal/privacy">{t('links.privacy')}</Link>
|
<Link to="/legal/privacy">{t('links.privacy')}</Link>
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import type { PondView } from '@dorfteich/shared';
|
import type { PondView } from '@dorfteich/shared';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
import { useDismissable } from '../lib/use-dismissable';
|
||||||
import { useCurrentPondRoute } from './use-pond-route';
|
import { useCurrentPondRoute } from './use-pond-route';
|
||||||
|
|
||||||
/** Top-bar dropdown to switch between the signed-in user's ponds (issue #26). */
|
/** 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 { user } = useAuth();
|
||||||
const { pondSlug } = useCurrentPondRoute();
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDismissable(menuRef, open, () => setOpen(false));
|
||||||
const ponds = useQuery({
|
const ponds = useQuery({
|
||||||
queryKey: ['ponds'],
|
queryKey: ['ponds'],
|
||||||
queryFn: () => apiGet<PondView[]>('/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);
|
const current = ponds.data.find((pond) => pond.slug === pondSlug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-menu">
|
<div className="user-menu" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="user-menu__trigger"
|
className="user-menu__trigger"
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import type { PondView } from '@dorfteich/shared';
|
import type { PondView } from '@dorfteich/shared';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Menu, Search, Settings } from 'lucide-react';
|
import { Menu, Search, Settings } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
import { useDismissable } from '../lib/use-dismissable';
|
||||||
import { SearchPalette } from '../search/SearchPalette';
|
import { SearchPalette } from '../search/SearchPalette';
|
||||||
import { NotificationsBell } from '../notifications/NotificationsBell';
|
import { NotificationsBell } from '../notifications/NotificationsBell';
|
||||||
import { usePageActionsSlot } from './page-actions';
|
import { usePageActionsSlot } from './page-actions';
|
||||||
@ -31,6 +32,8 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDismissable(userMenuRef, menuOpen, () => setMenuOpen(false));
|
||||||
const { setElement: setPageActionsElement, setPresenceElement } = usePageActionsSlot();
|
const { setElement: setPageActionsElement, setPresenceElement } = usePageActionsSlot();
|
||||||
// Pond-settings shortcut next to the pond name (M10 follow-up): owners get
|
// 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.
|
// 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)} />}
|
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
|
||||||
{user && <NotificationsBell />}
|
{user && <NotificationsBell />}
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="user-menu">
|
<div className="user-menu" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="user-menu__trigger"
|
className="user-menu__trigger"
|
||||||
|
|||||||
@ -14,6 +14,9 @@ export interface PageActionsSlot {
|
|||||||
setElement: (element: HTMLElement | null) => void;
|
setElement: (element: HTMLElement | null) => void;
|
||||||
presenceElement: HTMLElement | null;
|
presenceElement: HTMLElement | null;
|
||||||
setPresenceElement: (element: HTMLElement | null) => void;
|
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>({
|
export const PageActionsSlotContext = createContext<PageActionsSlot>({
|
||||||
@ -21,6 +24,8 @@ export const PageActionsSlotContext = createContext<PageActionsSlot>({
|
|||||||
setElement: () => {},
|
setElement: () => {},
|
||||||
presenceElement: null,
|
presenceElement: null,
|
||||||
setPresenceElement: () => {},
|
setPresenceElement: () => {},
|
||||||
|
statusElement: null,
|
||||||
|
setStatusElement: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function usePageActionsSlot(): PageActionsSlot {
|
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 type { MemberRole, MemberView, SeatedMemberRole } from '@dorfteich/shared';
|
||||||
|
import { UserMinus, UserPlus } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@ -139,8 +140,14 @@ export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button className="button member-add__submit" type="submit" disabled={addBlockedByQuota}>
|
<button
|
||||||
{t('add.submit')}
|
className="icon-button member-add__submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={addBlockedByQuota}
|
||||||
|
aria-label={t('add.submit')}
|
||||||
|
title={t('add.submit')}
|
||||||
|
>
|
||||||
|
<UserPlus aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
{addBlockedByQuota && (
|
{addBlockedByQuota && (
|
||||||
<p className="member-add__quota-full" role="note">
|
<p className="member-add__quota-full" role="note">
|
||||||
@ -231,8 +238,14 @@ function MemberRow({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="button member-row__remove" onClick={onRemove}>
|
<button
|
||||||
{t('actions.remove')}
|
type="button"
|
||||||
|
className="icon-button member-row__remove"
|
||||||
|
aria-label={t('actions.remove')}
|
||||||
|
title={t('actions.remove')}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<UserMinus aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import type { NotificationListView, NotificationView } from '@dorfteich/shared';
|
import type { NotificationListView, NotificationView } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Bell } from 'lucide-react';
|
import { Bell } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { apiGet, apiPost } from '../lib/api';
|
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,
|
* 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 navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const bellRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDismissable(bellRef, open, () => setOpen(false));
|
||||||
|
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ['notifications'],
|
queryKey: ['notifications'],
|
||||||
@ -44,13 +47,14 @@ export function NotificationsBell(): React.JSX.Element {
|
|||||||
const unread = list.data?.unreadCount ?? 0;
|
const unread = list.data?.unreadCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="notifications-bell">
|
<div className="notifications-bell" ref={bellRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="notifications-bell__button"
|
className="notifications-bell__button"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={t('title')}
|
aria-label={t('title')}
|
||||||
|
title={t('title')}
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
>
|
>
|
||||||
<Bell aria-hidden />
|
<Bell aria-hidden />
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared';
|
import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Copy,
|
Copy,
|
||||||
@ -8,17 +9,19 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Save,
|
||||||
Tag,
|
Tag,
|
||||||
Trash2,
|
Trash2,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { IconButton } from '../components/IconButton';
|
import { IconButton } from '../components/IconButton';
|
||||||
import { useDocumentExport } from '../export/use-document-export';
|
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';
|
import { WatchToggle } from '../watches/WatchToggle';
|
||||||
|
|
||||||
interface PageActionsProps {
|
interface PageActionsProps {
|
||||||
@ -58,6 +61,7 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{props.mode === 'edit' ? <BookOpen aria-hidden /> : <Pencil aria-hidden />}
|
{props.mode === 'edit' ? <BookOpen aria-hidden /> : <Pencil aria-hidden />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
|
||||||
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
|
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
|
||||||
<IconButton
|
<IconButton
|
||||||
className="editor-shell__comments-toggle"
|
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
|
/** Overflow "…" menu: Markdown copy/download (#30), office/PDF export
|
||||||
* (#65/#67), and the destructive move-to-trash (#31, keeps its confirm). */
|
* (#65/#67), and the destructive move-to-trash (#31, keeps its confirm). */
|
||||||
function PageOverflowMenu({
|
function PageOverflowMenu({
|
||||||
@ -131,6 +169,8 @@ function PageOverflowMenu({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
const { status, exportPage } = useDocumentExport();
|
const { status, exportPage } = useDocumentExport();
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDismissable(menuRef, open, () => setOpen(false));
|
||||||
|
|
||||||
async function copyMarkdown(): Promise<void> {
|
async function copyMarkdown(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -156,7 +196,7 @@ function PageOverflowMenu({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-actions__more">
|
<div className="page-actions__more" ref={menuRef}>
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t('page.moreActions')}
|
label={t('page.moreActions')}
|
||||||
active={open}
|
active={open}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { PageListItemView, PageStateView, PondView } from '@dorfteich/share
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration';
|
import { Collaboration } from '@tiptap/extension-collaboration';
|
||||||
import { EditorContent, useEditor } from '@tiptap/react';
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
|
import { RefreshCw, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -50,6 +51,13 @@ const DEFAULT_POND_FONTS = {
|
|||||||
|
|
||||||
type Mode = 'view' | 'edit';
|
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
|
/** 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). */
|
* or from the offline page cache after a reload without a connection (#38). */
|
||||||
interface ResolvedPage {
|
interface ResolvedPage {
|
||||||
@ -83,7 +91,7 @@ function PageEditor({
|
|||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { presenceElement } = usePageActionsSlot();
|
const { presenceElement, statusElement } = usePageActionsSlot();
|
||||||
|
|
||||||
// Created and destroyed within the same effect (not `useMemo` + a separate
|
// Created and destroyed within the same effect (not `useMemo` + a separate
|
||||||
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
||||||
@ -192,7 +200,7 @@ function PageEditor({
|
|||||||
return (
|
return (
|
||||||
<WikilinkContext.Provider value={wikilinks}>
|
<WikilinkContext.Provider value={wikilinks}>
|
||||||
<PluginBlockContext.Provider value={pluginBlockScope}>
|
<PluginBlockContext.Provider value={pluginBlockScope}>
|
||||||
<div className="editor-shell">
|
<div className={mode === 'view' ? 'editor-shell editor-shell--reading' : 'editor-shell'}>
|
||||||
<SectionStyleSheets plugins={pondPlugins.data} />
|
<SectionStyleSheets plugins={pondPlugins.data} />
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
|
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
|
||||||
@ -211,9 +219,22 @@ function PageEditor({
|
|||||||
{showComments && (
|
{showComments && (
|
||||||
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
|
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
|
||||||
)}
|
)}
|
||||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
{/* The connection status renders as an icon in the content footer
|
||||||
{t(`connection.${collab.status}`)}
|
(left half); the localized text stays for screen readers and as
|
||||||
</div>
|
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
|
{/* Live presence renders in the TopBar next to the page actions
|
||||||
(#102). The slot only exists for signed-in users, and the
|
(#102). The slot only exists for signed-in users, and the
|
||||||
public read view never mounts this editor (or any awareness
|
public read view never mounts this editor (or any awareness
|
||||||
@ -389,6 +410,9 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
onCloseAttachments={() => setShowAttachments(false)}
|
onCloseAttachments={() => setShowAttachments(false)}
|
||||||
onCloseComments={() => setShowComments(false)}
|
onCloseComments={() => setShowComments(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||||
|
{(showLabels || showHistory) && (
|
||||||
|
<div className="editor-page__panels">
|
||||||
{showLabels && (
|
{showLabels && (
|
||||||
<LabelPicker
|
<LabelPicker
|
||||||
pageId={resolved.id}
|
pageId={resolved.id}
|
||||||
@ -401,6 +425,8 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* "Linked from" appears below the content in read mode (issue #48). */}
|
{/* "Linked from" appears below the content in read mode (issue #48). */}
|
||||||
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
<div className="pond-settings-page">
|
<div className="pond-settings-page">
|
||||||
<div className="pond-settings-page__header">
|
<div className="pond-settings-page__header">
|
||||||
<h1>{pond.data.name}</h1>
|
<h1>{pond.data.name}</h1>
|
||||||
<WatchToggle targetType="pond" targetId={pond.data.id} />
|
<WatchToggle targetType="pond" targetId={pond.data.id} variant="icon" />
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h2>{tMembers('title')}</h2>
|
<h2>{tMembers('title')}</h2>
|
||||||
|
|||||||
@ -162,7 +162,23 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
|||||||
{debounced.length === 0 ? (
|
{debounced.length === 0 ? (
|
||||||
recent.length > 0 ? (
|
recent.length > 0 ? (
|
||||||
<div className="search-palette__recent">
|
<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>
|
<ul>
|
||||||
{recent.map((q) => (
|
{recent.map((q) => (
|
||||||
<li key={q}>
|
<li key={q}>
|
||||||
|
|||||||
@ -495,6 +495,7 @@ button {
|
|||||||
/* Legal pages + footer (issue #82) */
|
/* Legal pages + footer (issue #82) */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-8);
|
margin-top: var(--space-8);
|
||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
@ -503,6 +504,15 @@ button {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-footer__spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.legal-page {
|
.legal-page {
|
||||||
max-width: 44rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
@ -804,6 +814,17 @@ button {
|
|||||||
border-radius: var(--radius);
|
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 {
|
.editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -992,12 +1013,18 @@ button {
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The connection-status icon in the content footer (left half). */
|
||||||
.editor-connection {
|
.editor-connection {
|
||||||
padding: var(--space-1) var(--space-3);
|
display: inline-flex;
|
||||||
font-size: 0.85rem;
|
align-items: center;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-connection svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-connection[data-status='connected'] {
|
.editor-connection[data-status='connected'] {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
@ -1106,6 +1133,22 @@ button {
|
|||||||
min-width: 0;
|
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 {
|
.history-panel {
|
||||||
flex: 0 0 22rem;
|
flex: 0 0 22rem;
|
||||||
max-width: 22rem;
|
max-width: 22rem;
|
||||||
@ -1157,6 +1200,43 @@ button {
|
|||||||
background: var(--color-bg-subtle);
|
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 {
|
.history-panel__when {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { WatchListView } from '@dorfteich/shared';
|
import type { WatchListView } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { EyeOff } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
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>
|
<span className="watches-list__type">{t(`settings.types.${watch.targetType}`)}</span>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => void unwatch(watch.targetType, watch.targetId)}
|
||||||
>
|
>
|
||||||
{t('settings.unwatch')}
|
<EyeOff aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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.",
|
"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",
|
"diffTitle": "Änderungen von dieser Version bis heute",
|
||||||
"viewTitle": "Vorschau",
|
"viewTitle": "Vorschau",
|
||||||
"contributors_one": "{{count}} Beitragende:r",
|
|
||||||
"contributors_other": "{{count}} Beitragende",
|
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"auto": "Automatischer Schnappschuss",
|
"auto": "Automatischer Schnappschuss",
|
||||||
"manual": "Benannte Version",
|
"manual": "Benannte Version",
|
||||||
"pre_restore": "Vor einer Wiederherstellung"
|
"pre_restore": "Vor einer Wiederherstellung"
|
||||||
}
|
},
|
||||||
|
"saveVersion": "Version speichern",
|
||||||
|
"savePrompt": "Name der Version:",
|
||||||
|
"saveFailed": "Version konnte nicht gespeichert werden.",
|
||||||
|
"showAllContributors": "Alle Mitwirkenden anzeigen"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"copyMarkdown": "Als Markdown kopieren",
|
"copyMarkdown": "Als Markdown kopieren",
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"hint": "Tippe, um deine Seiten zu durchsuchen.",
|
"hint": "Tippe, um deine Seiten zu durchsuchen.",
|
||||||
"error": "Suche fehlgeschlagen. Bitte erneut versuchen.",
|
"error": "Suche fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
"recent": "Letzte Suchen",
|
"recent": "Letzte Suchen",
|
||||||
|
"recentClear": "Liste löschen",
|
||||||
"resultsLabel": "Suchergebnisse",
|
"resultsLabel": "Suchergebnisse",
|
||||||
"inPond": "in {{pond}}",
|
"inPond": "in {{pond}}",
|
||||||
"close": "Schließen"
|
"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.",
|
"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",
|
"diffTitle": "Changes from this version to now",
|
||||||
"viewTitle": "Preview",
|
"viewTitle": "Preview",
|
||||||
"contributors_one": "{{count}} contributor",
|
|
||||||
"contributors_other": "{{count}} contributors",
|
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"auto": "Automatic snapshot",
|
"auto": "Automatic snapshot",
|
||||||
"manual": "Named version",
|
"manual": "Named version",
|
||||||
"pre_restore": "Before a restore"
|
"pre_restore": "Before a restore"
|
||||||
}
|
},
|
||||||
|
"saveVersion": "Save version",
|
||||||
|
"savePrompt": "Version name:",
|
||||||
|
"saveFailed": "The version could not be saved.",
|
||||||
|
"showAllContributors": "Show all contributors"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"copyMarkdown": "Copy as Markdown",
|
"copyMarkdown": "Copy as Markdown",
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"hint": "Type to search your pages.",
|
"hint": "Type to search your pages.",
|
||||||
"error": "Search failed. Please try again.",
|
"error": "Search failed. Please try again.",
|
||||||
"recent": "Recent searches",
|
"recent": "Recent searches",
|
||||||
|
"recentClear": "Clear list",
|
||||||
"resultsLabel": "Search results",
|
"resultsLabel": "Search results",
|
||||||
"inPond": "in {{pond}}",
|
"inPond": "in {{pond}}",
|
||||||
"close": "Close"
|
"close": "Close"
|
||||||
|
|||||||
@ -98,6 +98,8 @@ export interface PageVersionView {
|
|||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
/** Users who edited since the previous version. */
|
/** Users who edited since the previous version. */
|
||||||
contributorIds: string[];
|
contributorIds: string[];
|
||||||
|
/** Display names for contributorIds; deleted accounts are omitted. */
|
||||||
|
contributors: { id: string; name: string }[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user