Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 116 additions & 15 deletions frontend/src/pages/editArticleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@
{ 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 = () => {
Expand Down Expand Up @@ -190,8 +192,13 @@
const [slugInput, setSlugInput] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isAutoSaving, setIsAutoSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [autoSaveMessage, setAutoSaveMessage] = useState<string | null>(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.
Expand Down Expand Up @@ -235,6 +242,49 @@
const [mediaError, setMediaError] = useState<string | null>(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) {
Expand Down Expand Up @@ -495,10 +545,27 @@
}
}, [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"
Expand All @@ -516,24 +583,23 @@
// 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
}

Expand Down Expand Up @@ -607,15 +673,45 @@
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])

Check warning on line 713 in frontend/src/pages/editArticleView.tsx

View workflow job for this annotation

GitHub Actions / frontend

React Hook useEffect has a missing dependency: 'saveArticle'. Either include it or remove the dependency array

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"
Expand Down Expand Up @@ -1088,6 +1184,11 @@
{successMessage}
</p>
)}
{!isNew && (isAutoSaving || autoSaveMessage) ? (
<p className="text-xs text-muted-foreground bg-muted/60 rounded-lg px-3 py-2">
{isAutoSaving ? "Autosaving..." : autoSaveMessage}
</p>
) : null}

<div className="flex flex-col gap-2 pt-1">
<button
Expand Down
61 changes: 41 additions & 20 deletions server/internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,34 @@ func loadArticleCategoriesByArchiveState(ctx context.Context, conn *sql.DB, slug
return parseArticleCategoryValue(raw), true, nil
}

func loadArticlePublishDate(ctx context.Context, conn *sql.DB, slug string) (sql.NullTime, error) {
var publishedAt sql.NullTime
err := conn.QueryRowContext(ctx, "SELECT pub_date FROM articles WHERE slug = ? AND archived_at IS NULL LIMIT 1", slug).Scan(&publishedAt)
if err == sql.ErrNoRows {
return sql.NullTime{}, nil
}
return publishedAt, err
}

func articlePatchDateColumns(statusSet bool, status models.ArticleStatus, publishedDateSet bool, publishedDateValue, scheduledDateValue any, currentPublishedAt sql.NullTime, now time.Time) ([]string, []any) {
if statusSet && status == models.ArticleStatusDraft {
return []string{"pub_date", "scheduled_pub_date"}, []any{nil, nil}
}
if publishedDateSet {
return []string{"pub_date", "scheduled_pub_date"}, []any{publishedDateValue, scheduledDateValue}
}
if statusSet && status == models.ArticleStatusPublished {
cols := []string{"scheduled_pub_date"}
args := []any{nil}
if !currentPublishedAt.Valid {
cols = append([]string{"pub_date"}, cols...)
args = append([]any{now.UTC().Format("2006-01-02 15:04:05")}, args...)
}
return cols, args
}
return nil, nil
}

func isValidCanonicalSlug(slug string) bool {
return db.IsCanonicalSlug(strings.TrimSpace(slug))
}
Expand Down Expand Up @@ -2105,6 +2133,11 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
currentPublishedAt, err := loadArticlePublishDate(r.Context(), conn, slug)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
nextCategories := oldCategories
if rawAuthors, ok := body["authors"]; ok {
parsedIDs, err := parseAuthorIDs(rawAuthors)
Expand Down Expand Up @@ -2135,7 +2168,7 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc {
var publishedDateValue any
var scheduledDateValue any
publishedDateSet := false
var statusValue any
var statusValue models.ArticleStatus
statusSet := false
for jsonField, column := range columnByJSONField {
v, ok := body[jsonField]
Expand Down Expand Up @@ -2189,10 +2222,10 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc {
status := models.ArticleStatus(strings.TrimSpace(s))
switch status {
case models.ArticleStatusDraft:
statusValue = nil
statusValue = status
statusSet = true
case models.ArticleStatusPublished:
statusValue = time.Now().UTC().Format("2006-01-02 15:04:05")
statusValue = status
statusSet = true
default:
writeError(w, http.StatusBadRequest, "status must be draft or published")
Expand Down Expand Up @@ -2224,29 +2257,17 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc {
setArgs = append(setArgs, v)
}
}
if statusSet || publishedDateSet {
setCols = append(setCols, "pub_date")
if statusSet && statusValue == nil {
setArgs = append(setArgs, nil)
} else if publishedDateSet {
setArgs = append(setArgs, publishedDateValue)
} else {
setArgs = append(setArgs, statusValue)
}
setCols = append(setCols, "scheduled_pub_date")
if statusSet && statusValue == nil {
setArgs = append(setArgs, nil)
} else if publishedDateSet {
setArgs = append(setArgs, scheduledDateValue)
} else {
setArgs = append(setArgs, nil)
}
if dateCols, dateArgs := articlePatchDateColumns(statusSet, statusValue, publishedDateSet, publishedDateValue, scheduledDateValue, currentPublishedAt, time.Now().UTC()); len(dateCols) > 0 {
setCols = append(setCols, dateCols...)
setArgs = append(setArgs, dateArgs...)
}
if len(setCols) == 0 && authorIDs == nil {
writeError(w, http.StatusBadRequest, "no valid fields to update")
return
}
targetSlug := slug
setCols = append(setCols, "mod_date")
setArgs = append(setArgs, time.Now().UTC().Format("2006-01-02 15:04:05"))
if len(setCols) > 0 {
result, err := db.Update(r.Context(), conn, "articles", setCols, "`slug` = ?", append(setArgs, slug)...)
if err != nil {
Expand Down
29 changes: 29 additions & 0 deletions server/internal/handlers/handlers_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package handlers

import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"

"server/internal/middleware"
"server/internal/models"
Expand Down Expand Up @@ -237,6 +239,33 @@ func TestArticleQueryFilters_FormatsDateFiltersWithGoReferenceLayout(t *testing.
}
}

func TestArticlePatchDateColumns_PublishedAutosavePreservesExistingPublishDate(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
currentPublishedAt := sql.NullTime{Time: time.Date(2026, 7, 1, 9, 30, 0, 0, time.UTC), Valid: true}

cols, args := articlePatchDateColumns(true, models.ArticleStatusPublished, false, nil, nil, currentPublishedAt, now)

if strings.Join(cols, ",") != "scheduled_pub_date" {
t.Fatalf("cols = %v, want only scheduled_pub_date", cols)
}
if len(args) != 1 || args[0] != nil {
t.Fatalf("args = %v, want scheduled_pub_date cleared without pub_date arg", args)
}
}

func TestArticlePatchDateColumns_PublishFromUnpublishedStampsPublishDate(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)

cols, args := articlePatchDateColumns(true, models.ArticleStatusPublished, false, nil, nil, sql.NullTime{}, now)

if strings.Join(cols, ",") != "pub_date,scheduled_pub_date" {
t.Fatalf("cols = %v, want pub_date and scheduled_pub_date", cols)
}
if len(args) != 2 || args[0] != "2026-08-02 12:00:00" || args[1] != nil {
t.Fatalf("args = %v, want pub_date stamped and scheduled_pub_date cleared", args)
}
}

func TestAuthorArchiveCondition_DefaultsToActiveAuthors(t *testing.T) {
got := authorArchiveCondition(url.Values{}, true)

Expand Down
Loading