From 6c38abc20ccc4f1ec537daab362f9d671aa9bfb9 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 13:05:32 +0200 Subject: [PATCH] Add backlinks panel and phantom-pages view (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make wikilink relations visible: what links here, and which linked pages do not exist yet. - shared: `BacklinkView` gains a plain-text `snippet` for context. - api: `LinksService` includes a short snippet (from the content cache) with each backlink and phantom referrer. - web: - `BacklinksPanel` below a page in read mode: a collapsible "Linked from" list (title + snippet, links to the source), hidden when empty. Appears on load from the #47 index. - `PhantomPagesView` in pond settings: wikilink targets that do not exist yet, each with its referrers and a create shortcut that makes the page under the phantom slug — resolving those links (#47) and navigating to it. - i18n `links` namespace (de + en); backlinks + missing-pages styles. - e2e `backlinks.spec.ts` (new CI pack): a link created in the editor appears as a backlink on the target; the missing-pages view lists a phantom slug and creating it navigates to the new page. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY --- .gitea/workflows/ci.yml | 10 +++ apps/api/src/links/links.service.db.test.ts | 4 +- apps/api/src/links/links.service.ts | 52 ++++++++++--- apps/web/e2e/backlinks.spec.ts | 86 +++++++++++++++++++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/links/BacklinksPanel.tsx | 45 +++++++++++ apps/web/src/links/PhantomPagesView.tsx | 81 +++++++++++++++++++ apps/web/src/pages/PageEditorPage.tsx | 3 + apps/web/src/pages/PondSettingsPage.tsx | 8 ++ apps/web/src/styles/base.css | 70 +++++++++++++++++ packages/shared/i18n/de/links.json | 14 ++++ packages/shared/i18n/en/links.json | 14 ++++ packages/shared/src/links.ts | 2 + 13 files changed, 380 insertions(+), 13 deletions(-) create mode 100644 apps/web/e2e/backlinks.spec.ts create mode 100644 apps/web/src/links/BacklinksPanel.tsx create mode 100644 apps/web/src/links/PhantomPagesView.tsx create mode 100644 packages/shared/i18n/de/links.json create mode 100644 packages/shared/i18n/en/links.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5dbc4fb..d276788 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -194,6 +194,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/wikilink.spec.ts + - name: Reset login rate limit before backlinks pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run backlinks pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts + - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true diff --git a/apps/api/src/links/links.service.db.test.ts b/apps/api/src/links/links.service.db.test.ts index 36e9dd7..799fb66 100644 --- a/apps/api/src/links/links.service.db.test.ts +++ b/apps/api/src/links/links.service.db.test.ts @@ -92,7 +92,9 @@ describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => { await link(source, `target-${suffix}`, target); const backlinks = await links.backlinks(owner, target); - expect(backlinks).toEqual([{ pageId: source, title: 'Source Page', slug: `source-${suffix}` }]); + expect(backlinks).toEqual([ + { pageId: source, title: 'Source Page', slug: `source-${suffix}`, snippet: '' }, + ]); }); it('hides backlinks from users who cannot see the pond', async () => { diff --git a/apps/api/src/links/links.service.ts b/apps/api/src/links/links.service.ts index 783b9b3..1fe569c 100644 --- a/apps/api/src/links/links.service.ts +++ b/apps/api/src/links/links.service.ts @@ -12,6 +12,17 @@ import { PrismaService } from '../prisma/prisma.service'; * within a pond, so every backlink lives in the same pond as its target — * seeing the pond (InterimAccessService) is therefore the read permission. */ +/** Longest plain-text preview shown next to a backlink (issue #48). */ +const SNIPPET_LENGTH = 140; + +/** The `fromPage` shape both queries select, for {@link viewOf}. */ +type LinkSource = { + id: string; + title: string; + slug: string; + contentCache: { plainText: string } | null; +}; + @Injectable() export class LinksService { constructor( @@ -19,6 +30,13 @@ export class LinksService { private readonly access: InterimAccessService, ) {} + private static viewOf(source: LinkSource): BacklinkView { + const text = source.contentCache?.plainText ?? ''; + const snippet = + text.length > SNIPPET_LENGTH ? `${text.slice(0, SNIPPET_LENGTH).trimEnd()}…` : text; + return { pageId: source.id, title: source.title, slug: source.slug, snippet }; + } + /** Pages linking to `pageId`, filtered to what the user may read (issue #47). */ async backlinks(user: User, pageId: string): Promise { const page = await this.prisma.page.findFirst({ @@ -30,7 +48,16 @@ export class LinksService { const links = await this.prisma.pageLink.findMany({ where: { toPageId: pageId, fromPage: { deletedAt: null } }, - include: { fromPage: { select: { id: true, title: true, slug: true } } }, + include: { + fromPage: { + select: { + id: true, + title: true, + slug: true, + contentCache: { select: { plainText: true } }, + }, + }, + }, orderBy: { fromPage: { title: 'asc' } }, }); const seen = new Set(); @@ -38,11 +65,7 @@ export class LinksService { for (const link of links) { if (seen.has(link.fromPage.id)) continue; seen.add(link.fromPage.id); - backlinks.push({ - pageId: link.fromPage.id, - title: link.fromPage.title, - slug: link.fromPage.slug, - }); + backlinks.push(LinksService.viewOf(link.fromPage)); } return backlinks; } @@ -54,7 +77,16 @@ export class LinksService { const rows = await this.prisma.pageLink.findMany({ where: { toPageId: null, fromPage: { pondId, deletedAt: null } }, - include: { fromPage: { select: { id: true, title: true, slug: true } } }, + include: { + fromPage: { + select: { + id: true, + title: true, + slug: true, + contentCache: { select: { plainText: true } }, + }, + }, + }, orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }], }); @@ -65,11 +97,7 @@ export class LinksService { entry = { targetSlug: row.targetSlug, referencedBy: [] }; grouped.set(row.targetSlug, entry); } - entry.referencedBy.push({ - pageId: row.fromPage.id, - title: row.fromPage.title, - slug: row.fromPage.slug, - }); + entry.referencedBy.push(LinksService.viewOf(row.fromPage)); } return [...grouped.values()]; } diff --git a/apps/web/e2e/backlinks.spec.ts b/apps/web/e2e/backlinks.spec.ts new file mode 100644 index 0000000..d9912c5 --- /dev/null +++ b/apps/web/e2e/backlinks.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Backlinks + missing-pages pack (issue #48). Drives the editor to create + * wikilinks (persisted via collab, indexed by #47), then checks the read-mode + * "Linked from" panel and the pond's missing-pages view. Language-independent + * selectors (CSS classes + page titles/slugs). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +type Ctx = Awaited>; + +async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +async function createPage(context: Ctx, pondId: string, title: string): Promise<{ slug: string }> { + const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } }); + return created.json(); +} + +/** Types a `[[query` in the editor and picks the first suggestion. */ +async function insertWikilink(page: import('@playwright/test').Page, query: string): Promise { + const body = page.locator('.editor-content .ProseMirror'); + await body.click(); + await page.keyboard.type(`[[${query}`); + await expect(page.locator('.wikilink-suggest')).toBeVisible(); + await page.keyboard.press('Enter'); +} + +test('a backlink appears on the target page after another page links it', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const ts = Date.now(); + const targetTitle = `BL Target ${ts}`; + const sourceTitle = `BL Source ${ts}`; + const target = await createPage(context, pond.id, targetTitle); + const source = await createPage(context, pond.id, sourceTitle); + + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${source.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + await insertWikilink(page, targetTitle); + // Reload disconnects the collab session, flushing the store → page_links row. + await page.reload(); + await expect(page.locator('.editor-content a.wikilink', { hasText: targetTitle })).toBeVisible(); + + // Open the target in read mode: the "Linked from" panel lists the source. + await page.goto(`/p/${pond.slug}/${target.slug}`); + const backlinks = page.locator('.backlinks'); + await expect(backlinks).toBeVisible(); + await expect(backlinks.locator('.backlinks__link', { hasText: sourceTitle })).toBeVisible(); + + await context.close(); +}); + +test('missing-pages view lists a phantom target and creates it', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const ts = Date.now(); + const ghostSlug = `ghost-page-${ts}`; + const source = await createPage(context, pond.id, `Ghost Source ${ts}`); + + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${source.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + // No page matches, so the only suggestion is the create-phantom hint. + await insertWikilink(page, ghostSlug); + await page.reload(); + await expect(page.locator('.editor-content a.wikilink.wikilink--phantom')).toBeVisible(); + + // The pond's missing-pages view lists the phantom slug. + await page.goto(`/p/${pond.slug}/settings`); + const item = page.locator('.phantom-pages__item', { hasText: ghostSlug }); + await expect(item).toBeVisible(); + + // Creating it navigates to the new page (slug = phantom slug). + await item.getByRole('button').click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${ghostSlug}$`)); + + await context.close(); +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 411b981..40d2bd6 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -3,12 +3,14 @@ import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; +import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; +import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; @@ -32,6 +34,7 @@ void i18n settings: enSettings, editor: enEditor, labels: enLabels, + links: enLinks, }, de: { common: deCommon, @@ -40,6 +43,7 @@ void i18n settings: deSettings, editor: deEditor, labels: deLabels, + links: deLinks, }, }, defaultNS: 'common', diff --git a/apps/web/src/links/BacklinksPanel.tsx b/apps/web/src/links/BacklinksPanel.tsx new file mode 100644 index 0000000..371185b --- /dev/null +++ b/apps/web/src/links/BacklinksPanel.tsx @@ -0,0 +1,45 @@ +import type { BacklinkView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { apiGet } from '../lib/api'; + +/** + * "Linked from" panel below a page in read mode (issue #48): the pages that + * wikilink here, each with a short snippet, from the server-maintained index + * (#47). Collapsible; hidden entirely when nothing links here so it adds no + * noise. Backlinks are already permission-filtered by the api. + */ +export function BacklinksPanel({ + pageId, + pondSlug, +}: { + pageId: string; + pondSlug: string; +}): React.JSX.Element | null { + const { t } = useTranslation('links'); + const backlinks = useQuery({ + queryKey: ['backlinks', pageId], + queryFn: () => apiGet(`/pages/${pageId}/backlinks`), + }); + + // Nothing to show until loaded; stay invisible when there are no backlinks. + if (!backlinks.data || backlinks.data.length === 0) return null; + + return ( +
+ {t('backlinks.toggle', { count: backlinks.data.length })} +
    + {backlinks.data.map((link) => ( +
  • + + {link.title} + + {link.snippet &&

    {link.snippet}

    } +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/links/PhantomPagesView.tsx b/apps/web/src/links/PhantomPagesView.tsx new file mode 100644 index 0000000..e398d4d --- /dev/null +++ b/apps/web/src/links/PhantomPagesView.tsx @@ -0,0 +1,81 @@ +import type { PageView, PhantomLinkView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate } from 'react-router-dom'; + +import { apiGet, apiPost } from '../lib/api'; + +/** + * Pond "missing pages" view (issue #48): wikilink targets that do not exist yet + * (`to_page_id` null in the index, #47), each with the pages referencing it and + * a shortcut that creates the page under the phantom slug — which resolves those + * links. Shown in pond settings for users who may edit the pond. + */ +export function PhantomPagesView({ + pondId, + pondSlug, +}: { + pondId: string; + pondSlug: string; +}): React.JSX.Element { + const { t } = useTranslation('links'); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [busy, setBusy] = useState(null); + + const phantoms = useQuery({ + queryKey: ['phantom-links', pondId], + queryFn: () => apiGet(`/ponds/${pondId}/phantom-links`), + }); + + async function createPage(targetSlug: string): Promise { + setBusy(targetSlug); + try { + // Title = the slug, so the generated slug equals the phantom target and + // the referencing links resolve to the new page (#47). + const page = await apiPost(`/ponds/${pondId}/pages`, { title: targetSlug }); + await queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] }); + await queryClient.invalidateQueries({ queryKey: ['pages', pondId] }); + navigate(`/p/${pondSlug}/${page.slug}`); + } finally { + setBusy(null); + } + } + + return ( +
+

{t('missing.description')}

+ {!phantoms.data ? null : phantoms.data.length === 0 ? ( +

{t('missing.empty')}

+ ) : ( +
    + {phantoms.data.map((phantom) => ( +
  • +
    + {phantom.targetSlug} + +
    +

    + {t('missing.referencedBy')}:{' '} + {phantom.referencedBy.map((ref, index) => ( + + {index > 0 && ', '} + {ref.title} + + ))} +

    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 68e9559..1e1df7f 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -12,6 +12,7 @@ import { FormError } from '../components/forms'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { HistoryPanel } from '../editor/HistoryPanel'; import { LabelPicker } from '../labels/LabelPicker'; +import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; import { ImageUpload } from '../editor/image-upload'; @@ -327,6 +328,8 @@ export function PageEditorPage(): React.JSX.Element { )} {showHistory && setShowHistory(false)} />} + {/* "Linked from" appears below the content in read mode (issue #48). */} + {mode === 'view' && } ); } diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 9c35276..a69b5bf 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; import { LabelManager } from '../labels/LabelManager'; +import { PhantomPagesView } from '../links/PhantomPagesView'; import { apiGet } from '../lib/api'; /** @@ -16,6 +17,7 @@ import { apiGet } from '../lib/api'; */ export function PondSettingsPage(): React.JSX.Element { const { t } = useTranslation('labels'); + const { t: tLinks } = useTranslation('links'); const { t: tErrors } = useTranslation('errors'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -43,6 +45,12 @@ export function PondSettingsPage(): React.JSX.Element {

)} + {canModify && ( +
+

{tLinks('missing.title')}

+ +
+ )} ); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index f38ca2a..e446676 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1230,3 +1230,73 @@ button { .wikilink-suggest__item:hover { background: var(--color-bg-subtle); } + +/* Backlinks + missing pages (issue #48) --------------------------------- */ + +.backlinks { + margin-top: var(--space-6); + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +.backlinks > summary { + cursor: pointer; + font-weight: 600; + color: var(--color-text-muted); +} + +.backlinks__list { + list-style: none; + margin: var(--space-2) 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.backlinks__link { + font-weight: 600; +} + +.backlinks__snippet { + margin: 2px 0 0; + color: var(--color-text-muted); + font-size: 0.9rem; +} + +.phantom-pages__description { + color: var(--color-text-muted); + margin-bottom: var(--space-3); +} + +.phantom-pages__empty { + color: var(--color-text-muted); +} + +.phantom-pages__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.phantom-pages__head { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.phantom-pages__slug { + font-family: var(--font-mono); + background: var(--color-bg-subtle); + padding: 2px var(--space-2); + border-radius: var(--radius); +} + +.phantom-pages__referrers { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); + font-size: 0.9rem; +} diff --git a/packages/shared/i18n/de/links.json b/packages/shared/i18n/de/links.json new file mode 100644 index 0000000..d9c7c20 --- /dev/null +++ b/packages/shared/i18n/de/links.json @@ -0,0 +1,14 @@ +{ + "backlinks": { + "title": "Verlinkt von", + "toggle": "Verlinkt von ({{count}})", + "empty": "Noch verweist keine andere Seite hierher." + }, + "missing": { + "title": "Fehlende Seiten", + "description": "Mit [[…]] verlinkte Seiten, die es noch nicht gibt.", + "empty": "Keine fehlenden Seiten — jeder Wikilink zeigt auf eine Seite.", + "referencedBy": "Verlinkt von", + "create": "Seite anlegen" + } +} diff --git a/packages/shared/i18n/en/links.json b/packages/shared/i18n/en/links.json new file mode 100644 index 0000000..2d0092a --- /dev/null +++ b/packages/shared/i18n/en/links.json @@ -0,0 +1,14 @@ +{ + "backlinks": { + "title": "Linked from", + "toggle": "Linked from ({{count}})", + "empty": "No other page links here yet." + }, + "missing": { + "title": "Missing pages", + "description": "Pages linked with [[…]] that do not exist yet.", + "empty": "No missing pages — every wikilink resolves to a page.", + "referencedBy": "Linked from", + "create": "Create page" + } +} diff --git a/packages/shared/src/links.ts b/packages/shared/src/links.ts index e00bd3e..b6635c3 100644 --- a/packages/shared/src/links.ts +++ b/packages/shared/src/links.ts @@ -9,6 +9,8 @@ export interface BacklinkView { pageId: string; title: string; slug: string; + /** A short plain-text preview of the linking page, for context (#48). */ + snippet: string; } /** A referenced-but-missing target and the pages that link to it. */