feat: the id, the slug and the marker at the top of the editor
Both identifiers were looked for and neither was on the page: the id had to be read out of the address bar and the slug out of a form field below the fold. They are now two chips under the title, each copying itself. The third chip is the one that earns its place — the cross-reference marker itself, `[[264|Pediatric Respiratory Failure]]`, id and title already assembled, because that is what somebody wants those two things *for*. And the broken-link check finally shows. The backend has computed `broken_links` on every save since markers existed and nothing had ever rendered it, which made the check pointless: a dead cross-reference found a week later belongs to nobody. Saving now says which markers point at nothing — and saves anyway, because a link to an article that has not been written yet is a note to write it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
dadd2447b9
commit
2d80df8d73
3 changed files with 114 additions and 1 deletions
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 && <div className="form-error" role="alert">{error}</div>}
|
||||
{broken.length > 0 && (
|
||||
<div className="article-broken" role="alert">
|
||||
<strong>
|
||||
{broken.length} cross-reference{broken.length === 1 ? '' : 's'} in this article
|
||||
point{broken.length === 1 ? 's' : ''} at nothing:
|
||||
</strong>
|
||||
<span>{broken.map(marker => <code key={marker}>{marker}</code>)}</span>
|
||||
<span className="article-broken-hint">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showRefine && user?.is_moderator && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<h4>AI refine</h4>
|
||||
|
|
@ -426,6 +456,27 @@ export function ArticlePage() {
|
|||
← Back to the article
|
||||
</button>
|
||||
<h1>{article.title} <DraftBadge status={article.status} /></h1>
|
||||
{/* 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. */}
|
||||
<div className="article-ids">
|
||||
<button type="button" className={`article-id${copied === 'id' ? ' is-copied' : ''}`}
|
||||
title="Copy this article's id" onClick={() => copyBit('id', String(article.id))}>
|
||||
<span>ID</span><code>{article.id}</code>
|
||||
</button>
|
||||
<button type="button" className={`article-id${copied === 'slug' ? ' is-copied' : ''}`}
|
||||
title="Copy this article's slug" onClick={() => copyBit('slug', article.slug)}>
|
||||
<span>Slug</span><code>{article.slug}</code>
|
||||
</button>
|
||||
<button type="button" className={`article-id is-marker${copied === 'marker' ? ' is-copied' : ''}`}
|
||||
title="Copy a link to this article, ready to paste into another one"
|
||||
onClick={() => copyBit('marker', `[[${article.id}|${article.title}]]`)}>
|
||||
<code>[[{article.id}|{article.title}]]</code>
|
||||
</button>
|
||||
{copied && <span className="article-id-said" role="status">Copied</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="article-header-actions">
|
||||
{/* A way out that is not Save. There was none: the only controls
|
||||
|
|
|
|||
|
|
@ -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(<MemoryRouter initialEntries={['/articles/1?edit=1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
|
||||
|
||||
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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue