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. */