diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 62bdc78..2e9075b 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -414,3 +414,48 @@ .article-danger p { margin: 0 0 10px; font-size: 0.86rem; line-height: 1.55; color: var(--text-muted); max-width: 62ch; } .article-danger-actions { display: flex; gap: 8px; flex-wrap: wrap; } .article-danger-open { color: var(--wrong-fg); } + +/* The identifiers, at the top of the editor. Both are looked for and neither + was anywhere on the page: the id had to be read out of the address bar and + the slug out of the form field below. + + The third chip is the useful one — the cross-reference marker itself, id and + title already assembled, because that is what somebody wants these two + numbers *for*. */ +.article-ids { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } +.article-id { + display: inline-flex; align-items: center; gap: 7px; cursor: pointer; + padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px; + background: var(--card-bg); font: inherit; font-size: 0.78rem; color: var(--text-muted); +} +.article-id:hover { border-color: var(--primary); color: var(--text); } +.article-id span { font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; font-size: 0.66rem; } +.article-id code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.76rem; color: var(--text); +} +.article-id.is-marker code { color: var(--primary); } +.article-id.is-marker { max-width: 100%; overflow: hidden; } +.article-id.is-marker code { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.article-id.is-copied { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); } +.article-id-said { font-size: 0.76rem; font-weight: 600; color: var(--primary); } + +@media (max-width: 560px) { + .article-id.is-marker { display: none; } /* The marker is a desktop errand. */ +} + +/* Cross-references that point at nothing, said on save — the last moment the + person who wrote the link is still looking at it. The backend has computed + this since markers existed; nothing had ever shown it. */ +.article-broken { + display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; + padding: 11px 14px; border-radius: 9px; + background: color-mix(in srgb, var(--wrong-fg) 7%, transparent); + border: 1px solid color-mix(in srgb, var(--wrong-fg) 24%, transparent); + font-size: 0.86rem; +} +.article-broken code { + display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 5px; + background: var(--card-bg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; +} +.article-broken-hint { font-size: 0.8rem; color: var(--text-muted); } diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 7f682ae..a1d6bbe 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -231,6 +231,12 @@ export function ArticlePage() { const [savedForm, setSavedForm] = useState(null) const [confirmDiscard, setConfirmDiscard] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + //: Which identifier was last copied, so the button can say so. Cleared on a + //: timer rather than left standing: "Copied" next to a button nobody has + //: pressed for a minute is a lie about the clipboard. + const [copied, setCopied] = useState('') + //: Cross-references in this article that point at nothing, as of the last save. + const [broken, setBroken] = useState([]) const [aiJob, setAiJob] = useState(null) const [aiMessage, setAiMessage] = useState('') const [showRefine, setShowRefine] = useState(false) @@ -301,6 +307,12 @@ export function ArticlePage() { //: next time a field is added. const dirty = !!form && !!savedForm && JSON.stringify(form) !== JSON.stringify(savedForm) + const copyBit = (which, value) => { + navigator.clipboard?.writeText(value) + setCopied(which) + setTimeout(() => setCopied(''), 2000) + } + const remove = async () => { setSaving(true); setError('') try { @@ -330,6 +342,11 @@ export function ArticlePage() { const payload = { ...form } const res = await api.patch(`/articles/${id}`, payload) setArticle(res.data) + // The backend has checked every cross-reference in the body and says + // which point at nothing. It has said so since the markers existed and + // nothing has ever shown it — a dead link found a week later belongs to + // nobody, which was the whole reason for checking on save. + setBroken(res.data?.broken_links || []) if (publish !== null) { await api.post(`/articles/${id}/publish`, { published: publish }) setArticle(prev => ({ ...prev, status: publish ? 'published' : 'draft' })) @@ -397,10 +414,23 @@ export function ArticlePage() { // Moderator business, and rare: kept above the reading rather than folded // into it, with the page's gutters back so an alert is not flush to the // window edge. - const hasNotices = !!error || (showRefine && user?.is_moderator) + const hasNotices = !!error || broken.length > 0 || (showRefine && user?.is_moderator) const notices = ( <> {error &&
{error}
} + {broken.length > 0 && ( +
+ + {broken.length} cross-reference{broken.length === 1 ? '' : 's'} in this article + point{broken.length === 1 ? 's' : ''} at nothing: + + {broken.map(marker => {marker})} + + Saved anyway — a link to an article that does not exist yet is a note to + write it. It will start working the moment that article does. + +
+ )} {showRefine && user?.is_moderator && (

AI refine

@@ -426,6 +456,27 @@ export function ArticlePage() { ← Back to the article

{article.title}

+ {/* The two identifiers, at the top where they are looked for, and + the marker that uses them. The id is what a cross-reference + should carry — a slug can be renamed, an id cannot — so the + third button hands over the whole thing, ready to paste into + another article. */} +
+ + + + {copied && Copied} +
{/* A way out that is not Save. There was none: the only controls diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index 437ea10..c34d8c7 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -256,6 +256,23 @@ describe('topic reading', () => { // three. The contents are filtered by the same choice as the body, so the // rail can never offer a heading the article is no longer showing — which is // the failure worth guarding, not the switch itself. + it('says which cross-references point at nothing, on save', async () => { + api.get.mockImplementation(url => (url === '/articles/1' + ? Promise.resolve({ data: article }) + : Promise.resolve({ data: [] }))) + api.patch.mockResolvedValue({ data: { ...article, broken_links: ['[[999|…]]', '[[no-such-topic]]'] } }) + render(} />) + + await userEvent.click(await screen.findByRole('button', { name: 'Save' })) + const notice = await screen.findByRole('alert') + expect(notice).toHaveTextContent('2 cross-references in this article point at nothing') + expect(within(notice).getByText('[[999|…]]')).toBeInTheDocument() + expect(within(notice).getByText('[[no-such-topic]]')).toBeInTheDocument() + // Said, not refused: a link to an article nobody has written yet is a note + // to write it. + expect(notice).toHaveTextContent('Saved anyway') + }) + it('keeps the contents rail in step with the depth being read', async () => { const layered = { ...article, sections: [ { id: 'a'.repeat(32), slug: 'key', title: 'Key points', content: 'Key body', variant: 'short' },