From e86cda09fc1e530c6c0ec85a61b59b0b7047650f Mon Sep 17 00:00:00 2001
From: ssavutu
Date: Sun, 2 Aug 2026 13:39:19 -0400
Subject: [PATCH] Add article autosave with publish date preservation
---
frontend/src/pages/editArticleView.tsx | 131 +++++++++++++++++++---
server/internal/handlers/handlers.go | 61 ++++++----
server/internal/handlers/handlers_test.go | 29 +++++
3 files changed, 186 insertions(+), 35 deletions(-)
diff --git a/frontend/src/pages/editArticleView.tsx b/frontend/src/pages/editArticleView.tsx
index 2c08b79..bb3fabc 100644
--- a/frontend/src/pages/editArticleView.tsx
+++ b/frontend/src/pages/editArticleView.tsx
@@ -133,6 +133,8 @@ const PUBLISH_TIMING_OPTIONS: Array<{ value: PublishTiming; label: string; blurb
{ value: "schedule", label: "Schedule", blurb: "Goes live at the publish date." },
]
+const AUTOSAVE_DELAY_MS = 2500
+
// ArticleView caches list results in sessionStorage keyed by query; clear those
// entries so the list refetches after an article is created or edited.
const clearArticleListCache = () => {
@@ -190,8 +192,13 @@ function EditArticleView() {
const [slugInput, setSlugInput] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
+ const [isAutoSaving, setIsAutoSaving] = useState(false)
const [error, setError] = useState(null)
const [successMessage, setSuccessMessage] = useState(null)
+ const [autoSaveMessage, setAutoSaveMessage] = useState(null)
+ const currentSnapshotRef = useRef("")
+ const lastSavedSnapshotRef = useRef("")
+ const snapshotInitializedRef = useRef(false)
// Name of another editor currently holding the edit lock, or null when we
// hold it (or it's a new article). When set, editing is blocked so nobody
// starts work they won't be able to save.
@@ -235,6 +242,49 @@ function EditArticleView() {
const [mediaError, setMediaError] = useState(null)
const [mediaSearch, setMediaSearch] = useState("")
const [customImageURL, setCustomImageURL] = useState("")
+ const articleSnapshot = useMemo(() => JSON.stringify({
+ title,
+ slugInput: isNew ? slugInput : "",
+ excerpt,
+ content,
+ publishTiming,
+ publishedAt: publishTiming === "schedule" ? publishedAt : "",
+ commentStatus,
+ photoURL,
+ breakingNews,
+ selectedCategorySlugs,
+ selectedAuthorId,
+ keyphrase,
+ metaDescription,
+ seoTitle,
+ }), [
+ title,
+ slugInput,
+ isNew,
+ excerpt,
+ content,
+ publishTiming,
+ publishedAt,
+ commentStatus,
+ photoURL,
+ breakingNews,
+ selectedCategorySlugs,
+ selectedAuthorId,
+ keyphrase,
+ metaDescription,
+ seoTitle,
+ ])
+
+ useEffect(() => {
+ currentSnapshotRef.current = articleSnapshot
+ }, [articleSnapshot])
+
+ useEffect(() => {
+ if (isLoading || snapshotInitializedRef.current) return
+ lastSavedSnapshotRef.current = articleSnapshot
+ currentSnapshotRef.current = articleSnapshot
+ snapshotInitializedRef.current = true
+ }, [articleSnapshot, isLoading])
useEffect(() => {
if (isNew) {
@@ -495,10 +545,27 @@ function EditArticleView() {
}
}, [apiFetch, imagePickerOpen, mediaItems.length, photoURL])
- const saveArticle = async (nextTiming?: PublishTiming) => {
- setIsSaving(true)
- setError(null)
- setSuccessMessage(null)
+ const saveArticle = async (nextTiming?: PublishTiming, options: { autosave?: boolean } = {}) => {
+ const autosave = options.autosave === true
+ const snapshotToSave = currentSnapshotRef.current
+ const setSavingState = autosave ? setIsAutoSaving : setIsSaving
+ const validationError = (message: string, autosaveMessage?: string) => {
+ if (autosave) {
+ setAutoSaveMessage(autosaveMessage ?? "Autosave paused.")
+ } else {
+ setError(message)
+ }
+ setSavingState(false)
+ }
+
+ setSavingState(true)
+ if (autosave) {
+ setAutoSaveMessage("Autosaving...")
+ } else {
+ setError(null)
+ setSuccessMessage(null)
+ setAutoSaveMessage(null)
+ }
const effectiveTiming = nextTiming ?? publishTiming
const effectiveStatus: EditableStatus = effectiveTiming === "draft" ? "draft" : "published"
@@ -516,24 +583,23 @@ function EditArticleView() {
// this only has to block on the way to published — where the row would
// otherwise vanish from both the CMS list and the public site.
if (effectiveStatus === "published" && !selectedAuthorId && categories.length === 0) {
- setError("Add at least one author or category so the article shows up in the list.")
- setIsSaving(false)
+ validationError(
+ "Add at least one author or category so the article shows up in the list.",
+ "Autosave paused until an author or section is set.",
+ )
return
}
const publishedDateISO = effectiveTiming === "schedule" && publishedAt ? localInputToISO(publishedAt) : ""
if (effectiveTiming === "schedule" && !publishedAt) {
- setError("Choose a publish date before scheduling.")
- setIsSaving(false)
+ validationError("Choose a publish date before scheduling.", "Autosave paused until the schedule date is set.")
return
}
if (effectiveTiming === "schedule" && publishedAt && !publishedDateISO) {
- setError("Publish date is invalid.")
- setIsSaving(false)
+ validationError("Publish date is invalid.", "Autosave paused until the schedule date is valid.")
return
}
if (effectiveTiming === "schedule" && !isFutureDate(publishedAt)) {
- setError("Choose a future publish date, or use Publish now.")
- setIsSaving(false)
+ validationError("Choose a future publish date, or use Publish now.", "Autosave paused until the schedule date is in the future.")
return
}
@@ -607,15 +673,45 @@ function EditArticleView() {
if (nextTiming !== "schedule") setPublishedAt("")
}
clearArticleListCache()
- setSuccessMessage("Article saved.")
+ lastSavedSnapshotRef.current = snapshotToSave
+ if (autosave) {
+ setAutoSaveMessage("Autosaved.")
+ } else {
+ setSuccessMessage("Article saved.")
+ }
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to save article."
- setError(message)
+ if (autosave) {
+ setAutoSaveMessage("Autosave failed.")
+ } else {
+ setError(message)
+ }
} finally {
- setIsSaving(false)
+ setSavingState(false)
}
}
+ useEffect(() => {
+ if (isNew || isLoading || lockedBy || isSaving || isAutoSaving) return
+ if (!snapshotInitializedRef.current) return
+ if (articleSnapshot === lastSavedSnapshotRef.current) return
+ if (publishTiming !== "draft" && !selectedAuthorId && selectedCategorySlugs.length === 0) {
+ setAutoSaveMessage("Autosave paused until an author or section is set.")
+ return
+ }
+ if (publishTiming === "schedule" && (!publishedAt || !isFutureDate(publishedAt))) {
+ setAutoSaveMessage("Autosave paused until the schedule date is valid.")
+ return
+ }
+
+ setAutoSaveMessage("Unsaved changes.")
+ const timer = window.setTimeout(() => {
+ void saveArticle(publishTiming, { autosave: true })
+ }, AUTOSAVE_DELAY_MS)
+
+ return () => window.clearTimeout(timer)
+ }, [articleSnapshot, isAutoSaving, isLoading, isNew, isSaving, lockedBy, publishTiming, publishedAt, selectedAuthorId, selectedCategorySlugs])
+
const inputClass ="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition"
const selectClass = "w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition"
const labelClass = "flex flex-col gap-1.5"
@@ -1088,6 +1184,11 @@ function EditArticleView() {
{successMessage}
)}
+ {!isNew && (isAutoSaving || autoSaveMessage) ? (
+
+ {isAutoSaving ? "Autosaving..." : autoSaveMessage}
+
+ ) : null}