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}

)} - @@ -255,10 +262,12 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El {ruleSentence(rule, t)} ))} diff --git a/apps/web/src/editor/HistoryPanel.tsx b/apps/web/src/editor/HistoryPanel.tsx index 3def2f9..c199183 100644 --- a/apps/web/src/editor/HistoryPanel.tsx +++ b/apps/web/src/editor/HistoryPanel.tsx @@ -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 ( + + {shown.join(', ')} + {collapsed && ( + <> + {', '} + + + )} + + ); +} + /** * 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({ {version.label ?? t(`history.trigger.${version.trigger}`)} - {version.contributorIds.length > 0 && - ` · ${t('history.contributors', { count: version.contributorIds.length })}`} + ))} diff --git a/apps/web/src/files/PondFileManager.tsx b/apps/web/src/files/PondFileManager.tsx index 7564375..15c967e 100644 --- a/apps/web/src/files/PondFileManager.tsx +++ b/apps/web/src/files/PondFileManager.tsx @@ -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 ))} diff --git a/apps/web/src/labels/LabelManager.tsx b/apps/web/src/labels/LabelManager.tsx index 764682a..9d8a564 100644 --- a/apps/web/src/labels/LabelManager.tsx +++ b/apps/web/src/labels/LabelManager.tsx @@ -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')} /> - @@ -245,10 +252,12 @@ function LabelNode({ @@ -269,8 +278,14 @@ function LabelNode({ aria-label={t('settings.addChild')} onChange={(event) => setChildName(event.target.value)} /> - - {isLoading ? null : flat.length === 0 ? ( + {isLoading ? null : flat.length === 0 && !mayManage ? (

{t('picker.empty')} {t('picker.manageHint')}

+ ) : flat.length === 0 ? ( +

{t('picker.empty')}

) : ( <> )} + + {/* Owners manage labels right here: quick create + the full manager + in the pond settings (M10 follow-up). */} + {mayManage && ( +
+
{ + event.preventDefault(); + void createLabel(); + }} + > + setNewName(event.target.value)} + /> + +
+ {createError && ( +

+ {createError} +

+ )} + {t('picker.manageHint')} +
+ )} ); } diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index c645e9e..0f9cc43 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -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(null); const [presenceElement, setPresenceElement] = useState(null); + const [statusElement, setStatusElement] = useState(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 diff --git a/apps/web/src/layout/Footer.tsx b/apps/web/src/layout/Footer.tsx index f4d3c41..6c4751a 100644 --- a/apps/web/src/layout/Footer.tsx +++ b/apps/web/src/layout/Footer.tsx @@ -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 (