fix: serialize article autosaves
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

This commit is contained in:
jb
2026-08-04 12:45:29 -04:00
parent 7ca6cb8458
commit 46f64d37b6
+44 -24
View File
@@ -23,7 +23,9 @@
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveSequence = 0;
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);
@@ -35,6 +37,40 @@
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) {
@@ -44,29 +80,13 @@
}
if (content === lastContent) return;
lastContent = content;
const sequence = ++saveSequence;
saveQueued = true;
saveState = 'saving';
const timer = window.setTimeout(async () => {
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
});
const result = await response.json();
if (sequence !== saveSequence) return;
if (response.status === 409) {
saveState = 'conflict';
return;
}
if (!response.ok) throw new Error(result.message || 'Draft save failed.');
version = result.version;
saveState = 'saved';
} catch (error) {
if (sequence !== saveSequence) return;
saveState = 'error';
toast.error('Draft not saved', error instanceof Error ? error.message : 'Try again.');
}
}, 900);
return () => window.clearTimeout(timer);
if (!saveInFlight) scheduleSave();
return () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = undefined;
};
});
function toggleTag(id: string) {
@@ -115,7 +135,7 @@
<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">{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
<button class="primary" disabled={saveState === 'saving' || coverUploading}>{visibility === 'private' ? 'SAVE AS PRIVATE' : data.article.kind === 'guide' ? 'SUBMIT FOR PUBLICATION' : 'PUBLISH ARTICLE'}</button>
</PendingForm>
</section>