Files
shd.build/src/lib/components/ArticleComposer.svelte
T
jb 46f64d37b6
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m11s
fix: serialize article autosaves
2026-08-04 12:45:29 -04:00

164 lines
15 KiB
Svelte

<script lang="ts">
import ArticleEditor from '$lib/components/ArticleEditor.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import { toast } from '$lib/toast';
import { untrack } from 'svelte';
let { data, form = null } = $props();
type ComposerTag = { id: string; name: string; group: string };
let title = $state(untrack(() => data.draft.title));
let deck = $state(untrack(() => data.draft.deck));
let document = $state(untrack(() => data.draft.document));
let selectedTags = $state<string[]>(untrack(() => [...data.draft.tagIds]));
let channel = $state(untrack(() => data.draft.channel || data.newsChannels?.[0] || ''));
let visibility = $state(untrack(() => data.article.visibility));
let coverImageId = $state(untrack(() => data.draft.coverImageId || ''));
let coverUrl = $state(untrack(() => data.draft.coverUrl || ''));
let coverAlt = $state(untrack(() => data.draft.coverAlt || ''));
let coverCaption = $state(untrack(() => data.draft.coverCaption || ''));
let version = $state(untrack(() => data.draft.version));
let saveState = $state<'saved' | 'saving' | 'error' | 'conflict'>('saved');
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveTimer: number | undefined;
let saveInFlight = false;
let saveQueued = false;
const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
return Object.entries(groups);
});
function payload() {
return { articleId: data.article.id, title, deck, document, tagIds: selectedTags, channel,
coverImageId, coverAlt, coverCaption, version };
}
function scheduleSave() {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = undefined;
void saveDraft();
}, 900);
}
async function saveDraft() {
if (saveInFlight || !saveQueued) return;
saveInFlight = true;
saveQueued = false;
const draft = { ...payload(), tagIds: [...selectedTags] };
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft)
});
const result = await response.json();
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = saveQueued ? 'saving' : 'saved';
} catch (error) {
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
} finally {
saveInFlight = false;
if (saveQueued && saveState !== 'conflict') scheduleSave();
}
}
$effect(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) {
initialized = true;
lastContent = content;
return;
}
if (content === lastContent) return;
lastContent = content;
saveQueued = true;
saveState = 'saving';
if (!saveInFlight) scheduleSave();
return () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = undefined;
};
});
function toggleTag(id: string) {
selectedTags = selectedTags.includes(id) ? selectedTags.filter((tag) => tag !== id) : [...selectedTags, id].slice(0, 12);
}
async function uploadCover(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
coverUploading = true;
const body = new FormData();
body.set('articleId', data.article.id);
body.set('image', file);
try {
const response = await fetch('/api/articles/media', { method: 'POST', body });
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Cover upload failed.');
coverImageId = result.id;
coverUrl = `/images/library/${result.id}`;
toast.success('Cover uploaded', 'Add alternative text before publishing.');
} catch (error) {
toast.error('Cover not uploaded', error instanceof Error ? error.message : 'Try another image.');
} finally {
coverUploading = false;
}
}
</script>
<main class="app-page article-compose-page">
<header class="article-compose-command">
<div><p>{data.article.kind === 'guide' ? 'GUIDE COMPOSER' : 'NEWSROOM'}</p><h1>{data.article.kind === 'guide' ? 'AUTHOR FIELD INTELLIGENCE' : 'PREPARE A TRANSMISSION'}</h1></div>
<div class="article-save-state" class:error={saveState === 'error' || saveState === 'conflict'}><i></i><span>{saveState === 'saving' ? 'SAVING DRAFT' : saveState === 'saved' ? 'DRAFT SAVED' : saveState === 'conflict' ? 'DRAFT CONFLICT · RELOAD' : 'SAVE FAILED'}</span></div>
</header>
{#if form?.articleError}<p class="error" role="alert">{form.articleError}</p>{/if}
{#if form?.articleMessage}<p class="success" role="status">{form.articleMessage}</p>{/if}
<section class="article-compose-lede" aria-label="Article headline">
<label class="article-title-field"><span>TITLE</span><textarea bind:value={title} maxlength="140" rows="1" placeholder="Give readers a clear reason to open this article"></textarea></label>
<label class="article-deck-field"><span>SUMMARY</span><textarea bind:value={deck} maxlength="320" rows="2" placeholder="Describe what the reader will learn."></textarea></label>
</section>
<section class="article-publication-dock" aria-label="Publication controls">
<div class="article-publication-ident"><span>PUBLICATION</span><strong>{data.article.kind.toUpperCase()}</strong></div>
<SegmentedChoice legend="VISIBILITY" name="composer-visibility" options={[{value:'private',label:'PRIVATE'},{value:'unlisted',label:'UNLISTED'},{value:'public',label:'PUBLIC'}]} value={visibility} onchange={(values) => visibility = values[0]}/>
{#if data.article.kind === 'news'}<label class="article-channel-field">NEWS CHANNEL<select bind:value={channel}>{#each data.newsChannels as item}<option>{item}</option>{/each}</select></label>{/if}
<div class="article-publication-note"><p>{visibility === 'private' ? 'Only you can open this draft.' : visibility === 'unlisted' ? 'Anyone with the direct URL can read it.' : 'Listed in discovery and visible to everyone.'}</p>{#if data.article.pendingRevisionId}<strong>MODERATION REVIEW PENDING</strong>{/if}</div>
<PendingForm action="?/publish" class="article-publish-form">
<input type="hidden" name="articleId" value={data.article.id}/><input type="hidden" name="title" value={title}/><input type="hidden" name="deck" value={deck}/><input type="hidden" name="document" value={document}/><input type="hidden" name="tagIds" value={JSON.stringify(selectedTags)}/><input type="hidden" name="channel" value={channel}/><input type="hidden" name="coverImageId" value={coverImageId}/><input type="hidden" name="coverAlt" value={coverAlt}/><input type="hidden" name="coverCaption" value={coverCaption}/><input type="hidden" name="version" value={version}/><input type="hidden" name="visibility" value={visibility}/>
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm>
</section>
<div class="article-compose-grid">
<section class="article-compose-main">
<ArticleEditor bind:value={document} articleId={data.article.id} builds={data.builds}/>
</section>
<aside class="article-compose-rail">
<section><header><span>COVER IMAGE</span></header>{#if coverUrl}<img class="article-cover-preview" src={coverUrl} alt="Selected article cover"/>{/if}<label class="article-file-action">{coverUploading ? 'UPLOADING…' : 'CHOOSE COVER'}<input type="file" accept="image/jpeg,image/png,image/webp,image/avif,image/svg+xml" disabled={coverUploading} onchange={uploadCover}/></label><label>ALTERNATIVE TEXT<input bind:value={coverAlt} maxlength="300"/></label><label>CAPTION · OPTIONAL<input bind:value={coverCaption} maxlength="500"/></label></section>
{#if data.article.kind === 'guide'}<section><header><span>DISCOVERY TAGS</span><b>{selectedTags.length}/12</b></header>{#each groupedTags as [group,tags]}<fieldset class="article-tag-group"><legend>{String(group).replace('_',' ').toUpperCase()}</legend><div>{#each tags as tag}<button type="button" class:selected={selectedTags.includes(tag.id)} aria-pressed={selectedTags.includes(tag.id)} onclick={() => toggleTag(tag.id)}>{tag.name}</button>{/each}</div></fieldset>{/each}</section>{/if}
{#if data.article.kind === 'guide'}<ModerationDisclosure/>{/if}
<PendingForm action="?/delete" class="article-delete-form"><input type="hidden" name="articleId" value={data.article.id}/><button class="danger" onclick={(event) => { if (!window.confirm('Delete this article? Its public page will be removed.')) event.preventDefault(); }}>DELETE ARTICLE</button></PendingForm>
</aside>
</div>
</main>
<style>
.article-compose-page{padding-top:18px}.article-compose-command{position:static;width:auto;height:auto;display:flex;align-items:end;justify-content:space-between;gap:20px;padding:12px 0 16px;border-bottom:1px solid var(--shd-border);background:transparent}.article-compose-command p,.article-compose-command h1{margin:0}.article-compose-command p{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.article-compose-command h1{font-size:clamp(1.6rem,4vw,3rem)}.article-save-state{display:flex;align-items:center;gap:7px;color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-save-state i{width:7px;height:7px;background:currentColor}.article-save-state.error{color:var(--shd-danger)}
.article-compose-lede{display:grid;gap:10px;padding:22px 0}.article-title-field,.article-deck-field{display:grid;gap:5px}.article-title-field span,.article-deck-field span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-title-field textarea,.article-deck-field textarea{field-sizing:content;overflow:hidden;resize:none}.article-title-field textarea{min-height:1.9em;padding:14px 0;border:0;border-bottom:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text);font-family:var(--shd-font);font-size:clamp(2.6rem,6vw,5.5rem);font-weight:700;line-height:.92;letter-spacing:-.035em}.article-deck-field textarea{min-height:3.2em;max-width:76ch;padding:8px 0;border:0;background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:clamp(1.08rem,2vw,1.3rem);line-height:1.5}
.article-publication-dock{display:grid;grid-template-columns:auto minmax(280px,340px) minmax(190px,240px) minmax(180px,1fr) auto;align-items:end;gap:12px;padding:12px;border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-publication-ident{align-self:center;display:grid;gap:3px;padding-right:12px;border-right:1px solid var(--shd-border)}.article-publication-ident span,.article-publication-ident strong,.article-channel-field,.article-publication-note{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-publication-ident span,.article-channel-field,.article-publication-note p{color:var(--shd-text-muted)}.article-publication-ident strong{color:var(--shd-text)}.article-channel-field{display:grid;gap:5px}.article-publication-note{align-self:center;display:grid;gap:3px}.article-publication-note p{margin:0;line-height:1.4}.article-publication-note strong{color:var(--shd-orange)}.article-publication-dock :global(.article-publish-form button){min-width:176px;min-height:44px}
.article-compose-grid{display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:start;gap:12px;margin-top:12px}.article-compose-main{min-width:0}.article-compose-rail{position:sticky;top:86px;display:grid;gap:8px}.article-compose-rail>section,.article-compose-rail :global(.moderation-disclosure){display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.article-compose-rail section>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;justify-content:space-between;gap:8px;padding:0 0 7px;border-bottom:1px solid var(--shd-border);background:transparent}.article-compose-rail section>header span,.article-compose-rail section>header b,.article-compose-rail label{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.article-compose-rail section>header span{color:var(--shd-text-muted)}.article-compose-rail label{display:grid;gap:5px;color:var(--shd-text-muted)}.article-cover-preview{width:100%;max-height:180px;object-fit:cover}.article-file-action{min-height:40px;display:flex!important;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text)!important;cursor:pointer}.article-file-action input{position:absolute;opacity:0;pointer-events:none}.article-tag-group{display:grid;gap:5px;margin:0;padding:0;border:0}.article-tag-group legend{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900}.article-tag-group>div{display:flex;flex-wrap:wrap;gap:4px}.article-tag-group button{min-height:32px;padding:0 7px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-tag-group button.selected{border-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.article-compose-rail :global(.article-delete-form button){width:100%;min-height:44px;border:1px solid #6f3b34;background:#1b1110;color:var(--shd-danger);font-weight:900}.article-compose-rail :global(.article-delete-form button:hover),.article-compose-rail :global(.article-delete-form button:focus-visible){border-color:var(--shd-danger);background:#2a1714;color:#f4a18e}
@media(max-width:1180px){.article-publication-dock{grid-template-columns:auto minmax(280px,1fr) minmax(200px,1fr) auto}.article-publication-note{grid-column:2/4}.article-compose-grid{grid-template-columns:minmax(0,1fr) 290px}}
@media(max-width:900px){.article-publication-dock{grid-template-columns:1fr 1fr}.article-publication-ident{padding:0 0 8px;border-right:0;border-bottom:1px solid var(--shd-border)}.article-publication-note{grid-column:1/-1}.article-publication-dock :global(.article-publish-form),.article-publication-dock :global(.article-publish-form button){width:100%}.article-compose-grid{grid-template-columns:1fr}.article-compose-rail{position:static;grid-template-columns:repeat(2,minmax(0,1fr))}.article-compose-rail :global(.article-delete-form),.article-compose-rail :global(.moderation-disclosure){grid-column:1/-1}}
@media(max-width:650px){.article-compose-command{align-items:start;flex-direction:column}.article-compose-lede{padding:18px 0}.article-title-field textarea{font-size:clamp(2.25rem,12vw,3.5rem)}.article-publication-dock,.article-compose-rail{grid-template-columns:1fr}.article-publication-ident,.article-publication-note,.article-publication-dock :global(.article-publish-form),.article-compose-rail :global(.article-delete-form),.article-compose-rail :global(.moderation-disclosure){grid-column:1}.article-publication-dock :global(.article-publish-form button){width:100%}}
</style>