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 \
|
||||
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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()];
|
||||
}
|
||||
|
||||
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 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',
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user