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
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:
parent
14e69b399c
commit
6c38abc20c
@ -194,6 +194,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/wikilink.spec.ts
|
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
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||||
|
|||||||
@ -92,7 +92,9 @@ describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
|
|||||||
await link(source, `target-${suffix}`, target);
|
await link(source, `target-${suffix}`, target);
|
||||||
|
|
||||||
const backlinks = await links.backlinks(owner, 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 () => {
|
it('hides backlinks from users who cannot see the pond', async () => {
|
||||||
|
|||||||
@ -12,6 +12,17 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
* within a pond, so every backlink lives in the same pond as its target —
|
* within a pond, so every backlink lives in the same pond as its target —
|
||||||
* seeing the pond (InterimAccessService) is therefore the read permission.
|
* 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()
|
@Injectable()
|
||||||
export class LinksService {
|
export class LinksService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -19,6 +30,13 @@ export class LinksService {
|
|||||||
private readonly access: InterimAccessService,
|
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). */
|
/** Pages linking to `pageId`, filtered to what the user may read (issue #47). */
|
||||||
async backlinks(user: User, pageId: string): Promise<BacklinkView[]> {
|
async backlinks(user: User, pageId: string): Promise<BacklinkView[]> {
|
||||||
const page = await this.prisma.page.findFirst({
|
const page = await this.prisma.page.findFirst({
|
||||||
@ -30,7 +48,16 @@ export class LinksService {
|
|||||||
|
|
||||||
const links = await this.prisma.pageLink.findMany({
|
const links = await this.prisma.pageLink.findMany({
|
||||||
where: { toPageId: pageId, fromPage: { deletedAt: null } },
|
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' } },
|
orderBy: { fromPage: { title: 'asc' } },
|
||||||
});
|
});
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -38,11 +65,7 @@ export class LinksService {
|
|||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
if (seen.has(link.fromPage.id)) continue;
|
if (seen.has(link.fromPage.id)) continue;
|
||||||
seen.add(link.fromPage.id);
|
seen.add(link.fromPage.id);
|
||||||
backlinks.push({
|
backlinks.push(LinksService.viewOf(link.fromPage));
|
||||||
pageId: link.fromPage.id,
|
|
||||||
title: link.fromPage.title,
|
|
||||||
slug: link.fromPage.slug,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return backlinks;
|
return backlinks;
|
||||||
}
|
}
|
||||||
@ -54,7 +77,16 @@ export class LinksService {
|
|||||||
|
|
||||||
const rows = await this.prisma.pageLink.findMany({
|
const rows = await this.prisma.pageLink.findMany({
|
||||||
where: { toPageId: null, fromPage: { pondId, deletedAt: null } },
|
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' } }],
|
orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -65,11 +97,7 @@ export class LinksService {
|
|||||||
entry = { targetSlug: row.targetSlug, referencedBy: [] };
|
entry = { targetSlug: row.targetSlug, referencedBy: [] };
|
||||||
grouped.set(row.targetSlug, entry);
|
grouped.set(row.targetSlug, entry);
|
||||||
}
|
}
|
||||||
entry.referencedBy.push({
|
entry.referencedBy.push(LinksService.viewOf(row.fromPage));
|
||||||
pageId: row.fromPage.id,
|
|
||||||
title: row.fromPage.title,
|
|
||||||
slug: row.fromPage.slug,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return [...grouped.values()];
|
return [...grouped.values()];
|
||||||
}
|
}
|
||||||
|
|||||||
86
apps/web/e2e/backlinks.spec.ts
Normal file
86
apps/web/e2e/backlinks.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -3,12 +3,14 @@ import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
|||||||
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
import deLabels from '@dorfteich/shared/i18n/de/labels.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 deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
||||||
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
||||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
import enLabels from '@dorfteich/shared/i18n/en/labels.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 enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
@ -32,6 +34,7 @@ void i18n
|
|||||||
settings: enSettings,
|
settings: enSettings,
|
||||||
editor: enEditor,
|
editor: enEditor,
|
||||||
labels: enLabels,
|
labels: enLabels,
|
||||||
|
links: enLinks,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
common: deCommon,
|
common: deCommon,
|
||||||
@ -40,6 +43,7 @@ void i18n
|
|||||||
settings: deSettings,
|
settings: deSettings,
|
||||||
editor: deEditor,
|
editor: deEditor,
|
||||||
labels: deLabels,
|
labels: deLabels,
|
||||||
|
links: deLinks,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
|||||||
45
apps/web/src/links/BacklinksPanel.tsx
Normal file
45
apps/web/src/links/BacklinksPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/src/links/PhantomPagesView.tsx
Normal file
81
apps/web/src/links/PhantomPagesView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import { FormError } from '../components/forms';
|
|||||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||||
import { HistoryPanel } from '../editor/HistoryPanel';
|
import { HistoryPanel } from '../editor/HistoryPanel';
|
||||||
import { LabelPicker } from '../labels/LabelPicker';
|
import { LabelPicker } from '../labels/LabelPicker';
|
||||||
|
import { BacklinksPanel } from '../links/BacklinksPanel';
|
||||||
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
||||||
import { documentExtensions } from '../editor/document-extensions';
|
import { documentExtensions } from '../editor/document-extensions';
|
||||||
import { ImageUpload } from '../editor/image-upload';
|
import { ImageUpload } from '../editor/image-upload';
|
||||||
@ -327,6 +328,8 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{showHistory && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
|
{showHistory && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
{/* "Linked from" appears below the content in read mode (issue #48). */}
|
||||||
|
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { LabelManager } from '../labels/LabelManager';
|
import { LabelManager } from '../labels/LabelManager';
|
||||||
|
import { PhantomPagesView } from '../links/PhantomPagesView';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,6 +17,7 @@ import { apiGet } from '../lib/api';
|
|||||||
*/
|
*/
|
||||||
export function PondSettingsPage(): React.JSX.Element {
|
export function PondSettingsPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation('labels');
|
const { t } = useTranslation('labels');
|
||||||
|
const { t: tLinks } = useTranslation('links');
|
||||||
const { t: tErrors } = useTranslation('errors');
|
const { t: tErrors } = useTranslation('errors');
|
||||||
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -43,6 +45,12 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tLinks('missing.title')}</h2>
|
||||||
|
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1230,3 +1230,73 @@ button {
|
|||||||
.wikilink-suggest__item:hover {
|
.wikilink-suggest__item:hover {
|
||||||
background: var(--color-bg-subtle);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
14
packages/shared/i18n/de/links.json
Normal file
14
packages/shared/i18n/de/links.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/shared/i18n/en/links.json
Normal file
14
packages/shared/i18n/en/links.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ export interface BacklinkView {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: 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. */
|
/** A referenced-but-missing target and the pages that link to it. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user