Add backlinks panel and phantom-pages view (#48)
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m15s
CI / Auth e2e pack (push) Successful in 2m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-09 13:05:32 +02:00
parent 14e69b399c
commit 6c38abc20c
13 changed files with 380 additions and 13 deletions

View File

@ -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

View File

@ -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 () => {

View File

@ -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<BacklinkView[]> {
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<string>();
@ -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()];
}

View File

@ -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<ReturnType<typeof contextForUser>>;
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<void> {
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();
});

View File

@ -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',

View File

@ -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<BacklinkView[]>(`/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 (
<details className="backlinks" open>
<summary>{t('backlinks.toggle', { count: backlinks.data.length })}</summary>
<ul className="backlinks__list">
{backlinks.data.map((link) => (
<li key={link.pageId} className="backlinks__item">
<Link to={`/p/${pondSlug}/${link.slug}`} className="backlinks__link">
{link.title}
</Link>
{link.snippet && <p className="backlinks__snippet">{link.snippet}</p>}
</li>
))}
</ul>
</details>
);
}

View File

@ -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<string | null>(null);
const phantoms = useQuery({
queryKey: ['phantom-links', pondId],
queryFn: () => apiGet<PhantomLinkView[]>(`/ponds/${pondId}/phantom-links`),
});
async function createPage(targetSlug: string): Promise<void> {
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<PageView>(`/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 (
<section className="phantom-pages" aria-label={t('missing.title')}>
<p className="phantom-pages__description">{t('missing.description')}</p>
{!phantoms.data ? null : phantoms.data.length === 0 ? (
<p className="phantom-pages__empty">{t('missing.empty')}</p>
) : (
<ul className="phantom-pages__list">
{phantoms.data.map((phantom) => (
<li key={phantom.targetSlug} className="phantom-pages__item">
<div className="phantom-pages__head">
<code className="phantom-pages__slug">{phantom.targetSlug}</code>
<button
type="button"
className="button"
disabled={busy === phantom.targetSlug}
onClick={() => void createPage(phantom.targetSlug)}
>
{t('missing.create')}
</button>
</div>
<p className="phantom-pages__referrers">
{t('missing.referencedBy')}:{' '}
{phantom.referencedBy.map((ref, index) => (
<span key={ref.pageId}>
{index > 0 && ', '}
<Link to={`/p/${pondSlug}/${ref.slug}`}>{ref.title}</Link>
</span>
))}
</p>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -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 && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
</div>
{/* "Linked from" appears below the content in read mode (issue #48). */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
</div>
);
}

View File

@ -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 {
</p>
)}
</section>
{canModify && (
<section>
<h2>{tLinks('missing.title')}</h2>
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
</section>
)}
</div>
);
}

View File

@ -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;
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

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