From 83fa23bbf9b1005af15e3eb3b632cdee16599a5b Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 12 Jul 2026 07:13:34 +0200 Subject: [PATCH] Polish round 2: content footer, dismissable menus, manual versions, substring search, icon actions in settings (M10 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .../src/search/postgres-search.provider.ts | 14 +++- apps/api/src/search/search.service.db.test.ts | 15 ++++ apps/api/src/versions/versions.service.ts | 28 +++++-- apps/web/src/access/AccessRulesManager.tsx | 17 +++- apps/web/src/editor/HistoryPanel.tsx | 45 +++++++++- apps/web/src/files/PondFileManager.tsx | 7 +- apps/web/src/labels/LabelManager.tsx | 27 ++++-- apps/web/src/labels/LabelPicker.tsx | 80 ++++++++++++++++-- apps/web/src/layout/AppLayout.tsx | 5 +- apps/web/src/layout/Footer.tsx | 8 +- apps/web/src/layout/PondSwitcher.tsx | 7 +- apps/web/src/layout/TopBar.tsx | 7 +- apps/web/src/layout/page-actions.tsx | 5 ++ apps/web/src/lib/use-dismissable.ts | 28 +++++++ apps/web/src/members/MemberManager.tsx | 21 ++++- .../src/notifications/NotificationsBell.tsx | 8 +- apps/web/src/pages/PageActions.tsx | 46 +++++++++- apps/web/src/pages/PageEditorPage.tsx | 56 +++++++++---- apps/web/src/pages/PondSettingsPage.tsx | 2 +- apps/web/src/search/SearchPalette.tsx | 18 +++- apps/web/src/styles/base.css | 84 ++++++++++++++++++- apps/web/src/watches/WatchesSection.tsx | 7 +- packages/shared/i18n/de/editor.json | 8 +- packages/shared/i18n/de/search.json | 1 + packages/shared/i18n/en/editor.json | 8 +- packages/shared/i18n/en/search.json | 1 + packages/shared/src/pages.ts | 2 + 27 files changed, 488 insertions(+), 67 deletions(-) create mode 100644 apps/web/src/lib/use-dismissable.ts diff --git a/apps/api/src/search/postgres-search.provider.ts b/apps/api/src/search/postgres-search.provider.ts index 8202037..4106bbc 100644 --- a/apps/api/src/search/postgres-search.provider.ts +++ b/apps/api/src/search/postgres-search.provider.ts @@ -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} diff --git a/apps/api/src/search/search.service.db.test.ts b/apps/api/src/search/search.service.db.test.ts index 4aa32f3..7e73ebd 100644 --- a/apps/api/src/search/search.service.db.test.ts +++ b/apps/api/src/search/search.service.db.test.ts @@ -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([]); diff --git a/apps/api/src/versions/versions.service.ts b/apps/api/src/versions/versions.service.ts index 7fc196d..3635f85 100644 --- a/apps/api/src/versions/versions.service.ts +++ b/apps/api/src/versions/versions.service.ts @@ -45,7 +45,7 @@ export class VersionsService { this.logger.setContext(VersionsService.name); } - viewOf(version: Omit): PageVersionView { + viewOf(version: Omit, names: Map): 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> { + 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). */ diff --git a/apps/web/src/access/AccessRulesManager.tsx b/apps/web/src/access/AccessRulesManager.tsx index e208256..e91674a 100644 --- a/apps/web/src/access/AccessRulesManager.tsx +++ b/apps/web/src/access/AccessRulesManager.tsx @@ -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}
{t('picker.empty')} {t('picker.manageHint')}
{t('picker.empty')}