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
20 changes: 15 additions & 5 deletions frontend/src/pages/articleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"
import { publicSiteUrl } from "../auth/urls"
import { useApiFetch } from "../hooks/useApiFetch"

type ArticleStatus = "Published" | "Draft" | "Archived"
type ArticleStatus = "Published" | "Scheduled" | "Draft" | "Archived"

type ArticleItem = {
id: string
Expand Down Expand Up @@ -51,9 +51,17 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]
const DEFAULT_PAGE_SIZE = 25
const AUTHORS_PAGE_SIZE = 200

const mapApiStatus = (status: string, activeTab: "all" | "trash"): ArticleStatus => {
const isFutureDate = (value?: string) => {
if (!value) return false
const timestamp = new Date(value).getTime()
return !Number.isNaN(timestamp) && timestamp > Date.now()
}

const mapApiStatus = (status: string, activeTab: "all" | "trash", publishedDate?: string): ArticleStatus => {
if (activeTab === "trash") return "Archived"
return status.toLowerCase() === "published" ? "Published" : "Draft"
if (status.toLowerCase() === "scheduled") return "Scheduled"
if (status.toLowerCase() !== "published") return "Draft"
return isFutureDate(publishedDate) ? "Scheduled" : "Published"
}

const formatArticleDate = (publishedDate?: string) => {
Expand Down Expand Up @@ -290,7 +298,7 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
.map((author) => (author.name ?? "").trim())
.filter((name) => name.length > 0)
.join(", "),
status: mapApiStatus(item.status, activeTab),
status: mapApiStatus(item.status, activeTab, item.published_date),
// Drafts have no published_date, so fall back to when the row was created.
date: formatArticleDate(item.published_date ?? item.creation_date),
slug: item.slug,
Expand Down Expand Up @@ -610,7 +618,9 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
item.status === "Published"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: item.status === "Draft"
: item.status === "Scheduled"
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
: item.status === "Draft"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
: "bg-slate-200 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200"
}`}>
Expand Down
77 changes: 74 additions & 3 deletions frontend/src/pages/editArticleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router-dom"
import { useApiFetch } from "../hooks/useApiFetch"
import { publicSiteUrl } from "../auth/urls"
import TrixEditor from "../components/TrixEditor"
import { DateTimeField } from "../components/ui/datetime-field"

// Lazy-loaded so the heavy yoastseo bundle only loads when editing an article.
const SeoAnalysis = lazy(() => import("../components/SeoAnalysis"))
Expand All @@ -17,6 +18,7 @@ type ApiArticleDetail = {
content: string
excerpt?: string
status?: string
published_date?: string
comment_status?: string
featured_image?: string
breaking_news?: boolean
Expand All @@ -40,6 +42,7 @@ type PatchPayload = {
excerpt: string
content: string
status: EditableStatus
published_date?: string
comment_status: string
photo_url: string
breaking_news: boolean
Expand Down Expand Up @@ -90,6 +93,39 @@ const slugifyCategory = (value: string): string =>
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")

const toLocalInput = (value?: string): string => {
if (!value) return ""
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}

const localInputToISO = (value: string): string => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
return date.toISOString()
}

const isFutureDate = (value: string): boolean => {
if (!value) return false
const timestamp = new Date(value).getTime()
return !Number.isNaN(timestamp) && timestamp > Date.now()
}

const formatPublishDate = (value: string): string => {
if (!value) return ""
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})
}

// 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 @@ -167,6 +203,7 @@ function EditArticleView() {
}, [excerpt])
const [content, setContent] = useState("")
const [status, setStatus] = useState<EditableStatus>("draft")
const [publishedAt, setPublishedAt] = useState("")
const [commentStatus, setCommentStatus] = useState("open")
const [photoURL, setPhotoURL] = useState("")
const [breakingNews, setBreakingNews] = useState(false)
Expand Down Expand Up @@ -223,7 +260,8 @@ function EditArticleView() {
// has been saved yet.
setMetaDescription(payload.seo?.meta_description ?? payload.excerpt ?? "")
setContent(payload.content ?? "")
setStatus((payload.status ?? "draft").toLowerCase() === "published" ? "published" : "draft")
setStatus(["published", "scheduled"].includes((payload.status ?? "draft").toLowerCase()) ? "published" : "draft")
setPublishedAt(toLocalInput(payload.published_date))
setCommentStatus(normalizeCommentStatus(payload.comment_status))
setPhotoURL(payload.featured_image ?? "")
setBreakingNews(Boolean(payload.breaking_news))
Expand Down Expand Up @@ -466,6 +504,12 @@ function EditArticleView() {
setIsSaving(false)
return
}
const publishedDateISO = effectiveStatus === "published" && publishedAt ? localInputToISO(publishedAt) : ""
if (effectiveStatus === "published" && publishedAt && !publishedDateISO) {
setError("Publish date is invalid.")
setIsSaving(false)
return
}

try {
if (isNew) {
Expand All @@ -478,6 +522,7 @@ function EditArticleView() {
content: content.trim(),
excerpt: excerpt.trim(),
status: effectiveStatus,
...(publishedDateISO ? { published_date: publishedDateISO } : {}),
comment_status: commentStatus.trim() || "open",
photo_url: photoURL.trim(),
breaking_news: breakingNews,
Expand Down Expand Up @@ -510,6 +555,7 @@ function EditArticleView() {
excerpt: excerpt.trim(),
content: content.trim(),
status: effectiveStatus,
...(publishedDateISO ? { published_date: publishedDateISO } : {}),
comment_status: commentStatus.trim() || "open",
photo_url: photoURL.trim(),
breaking_news: breakingNews,
Expand All @@ -532,6 +578,7 @@ function EditArticleView() {
}
if (nextStatus) {
setStatus(nextStatus)
if (nextStatus === "draft") setPublishedAt("")
}
clearArticleListCache()
setSuccessMessage("Article saved.")
Expand All @@ -547,6 +594,12 @@ function EditArticleView() {
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"
const labelTextClass = "text-xs font-semibold text-muted-foreground uppercase tracking-wide"
const publishDateInFuture = status === "published" && isFutureDate(publishedAt)
const publishDateHint = publishedAt
? publishDateInFuture
? `Goes on the site ${formatPublishDate(publishedAt)}.`
: `Published date: ${formatPublishDate(publishedAt)}.`
: "Blank publishes immediately."
const taxonomyBySlug = useMemo(() => new Map(taxonomyItems.map((item) => [item.slug, item])), [taxonomyItems])
const categoryGroups = useMemo(() => {
const sections = taxonomyItems
Expand Down Expand Up @@ -770,12 +823,30 @@ function EditArticleView() {

<label className={labelClass}>
<span className={labelTextClass}>Status</span>
<select className={selectClass} onChange={(e) => setStatus(e.target.value as EditableStatus)} value={status}>
<select
className={selectClass}
onChange={(e) => {
const next = e.target.value as EditableStatus
setStatus(next)
if (next === "draft") setPublishedAt("")
}}
value={status}
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</label>

{status === "published" ? (
<DateTimeField
label="Publish date"
value={publishedAt}
onChange={setPublishedAt}
clearable
hint={publishDateHint}
/>
) : null}

<div className={labelClass}>
<span className={labelTextClass}>Author</span>
<div className="relative">
Expand Down Expand Up @@ -989,7 +1060,7 @@ function EditArticleView() {
onClick={() => void saveArticle("published")}
type="button"
>
{isSaving ? "Publishing..." : "Publish"}
{isSaving ? (publishDateInFuture ? "Scheduling..." : "Publishing...") : (publishDateInFuture ? "Schedule" : "Publish")}
</button>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion server/internal/database/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ func GetArticleCommentTargetBySlug(ctx context.Context, conn *sql.DB, slug strin
SELECT id, COALESCE(comment_status, '')
FROM articles
WHERE slug = ?
AND pub_date IS NOT NULL
AND pub_date <= UTC_TIMESTAMP()
AND archived_at IS NULL
LIMIT 1
`, slug).Scan(&articleID, &commentStatus)
if err == nil {
Expand Down Expand Up @@ -286,7 +289,7 @@ func GetCommentByID(ctx context.Context, conn *sql.DB, id int64) (Comment, error

func ArticleExistsBySlug(ctx context.Context, conn *sql.DB, slug string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, "SELECT 1 FROM articles WHERE slug = ? LIMIT 1", slug).Scan(&exists)
err := conn.QueryRowContext(ctx, "SELECT 1 FROM articles WHERE slug = ? AND pub_date IS NOT NULL AND pub_date <= UTC_TIMESTAMP() AND archived_at IS NULL LIMIT 1", slug).Scan(&exists)
if err == nil {
return true, nil
}
Expand Down
42 changes: 34 additions & 8 deletions server/internal/database/http_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ var ArticleColumns = []string{
"id", "title", "slug", "description", "text", "excerpt", "tags", "categories",
"pub_date", "mod_date", "priority", "breaking_news",
"comment_status", "photo_url",
"focus_keyword", "meta_description", "seo_title", "creation_date",
"focus_keyword", "meta_description", "seo_title", "creation_date", "scheduled_pub_date",
}

const articleSelectColumnsQualified = "a.`id`, a.`title`, a.`slug`, a.`description`, a.`text`, a.`excerpt`, a.`tags`, a.`categories`, a.`pub_date`, a.`mod_date`, a.`priority`, a.`breaking_news`, a.`comment_status`, a.`photo_url`, a.`focus_keyword`, a.`meta_description`, a.`seo_title`, a.`creation_date`"
const articleSelectColumnsQualified = "a.`id`, a.`title`, a.`slug`, a.`description`, a.`text`, a.`excerpt`, a.`tags`, a.`categories`, a.`pub_date`, a.`mod_date`, a.`priority`, a.`breaking_news`, a.`comment_status`, a.`photo_url`, a.`focus_keyword`, a.`meta_description`, a.`seo_title`, a.`creation_date`, a.`scheduled_pub_date`"

// Image/photo URLs are canonicalized upstream in the WordPress ETL (see
// wordpress-etl Utils/MediaURL) so `photo_url` and inline body images are stored
Expand Down Expand Up @@ -145,12 +145,13 @@ func ScanArticle(rows *sql.Rows) (models.Article, error) {
metaDescription sql.NullString
seoTitle sql.NullString
creationDate sql.NullTime
scheduledDate sql.NullTime
)
err := rows.Scan(
&a.ID, &a.Title, &slug, &description, &text, &excerpt, &tags, &categories,
&pubDate, &ignoredMod, &priority, &breakingNews,
&commentStatus, &photoURL,
&focusKeyword, &metaDescription, &seoTitle, &creationDate,
&focusKeyword, &metaDescription, &seoTitle, &creationDate, &scheduledDate,
)
if err != nil {
return models.Article{}, err
Expand Down Expand Up @@ -185,6 +186,11 @@ func ScanArticle(rows *sql.Rows) (models.Article, error) {
t := pubDate.Time
a.PublishedAt = &t
a.Status = models.ArticleStatusPublished
} else if scheduledDate.Valid {
t := scheduledDate.Time
a.PublishedAt = &t
a.ScheduledAt = &t
a.Status = models.ArticleStatusScheduled
} else {
a.Status = models.ArticleStatusDraft
}
Expand Down Expand Up @@ -334,7 +340,7 @@ func GetRelatedArticlesBySlug(ctx context.Context, conn *sql.DB, slug string, k
// candidate must be live content regardless of who is asking -- an
// unpublished or soft-deleted article is not something to link to from
// anywhere, including the CMS preview.
"WHERE src.slug = ? AND a.pub_date IS NOT NULL AND a.archived_at IS NULL " +
"WHERE src.slug = ? AND a.pub_date IS NOT NULL AND a.pub_date <= UTC_TIMESTAMP() AND a.archived_at IS NULL " +
"ORDER BY VEC_DISTANCE_EUCLIDEAN(cand_vec.embedding, src_vec.embedding), a.id DESC " +
"LIMIT ?"

Expand Down Expand Up @@ -363,10 +369,10 @@ func SearchArticles(ctx context.Context, conn *sql.DB, term string, limit, offse
}

like := "%" + trimmedTerm + "%"
query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date` FROM `articles` " +
query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date`, `scheduled_pub_date` FROM `articles` " +
// Search is public, so it stays pinned to live content: published and not
// soft-deleted. Archived rows were previously reachable here.
"WHERE `pub_date` IS NOT NULL AND `archived_at` IS NULL AND (`title` LIKE ? OR `tags` LIKE ? OR `text` LIKE ?) " +
"WHERE `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL AND (`title` LIKE ? OR `tags` LIKE ? OR `text` LIKE ?) " +
"ORDER BY CASE " +
"WHEN `title` LIKE ? THEN 1 " +
"WHEN `tags` LIKE ? THEN 2 " +
Expand Down Expand Up @@ -578,8 +584,17 @@ func IsCanonicalSlug(value string) bool {

func ArticleInputToDBFields(body models.ArticleInput) []any {
var publishedAt any
var scheduledAt any
if body.Status == models.ArticleStatusPublished {
publishedAt = time.Now().UTC().Format("2006-01-02 15:04:05")
if supplied := ParsePublishedAt(body.PublishedDate); supplied != nil {
if supplied.After(time.Now().UTC()) {
publishedAt = nil
scheduledAt = supplied.UTC().Format("2006-01-02 15:04:05")
} else {
publishedAt = supplied.UTC().Format("2006-01-02 15:04:05")
}
}
}
slug := normalizeSlug(body.Slug)
if slug == "" {
Expand Down Expand Up @@ -615,13 +630,23 @@ func ArticleInputToDBFields(body models.ArticleInput) []any {
// Stamp creation_date so a draft has a date of its own: it is what the
// CMS listing sorts unpublished rows by (pub_date is NULL until publish).
time.Now().UTC().Format("2006-01-02 15:04:05"),
scheduledAt,
}
}

func ArticleToDBFields(body models.Article) []any {
var publishedAt any
if body.PublishedAt != nil {
publishedAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05")
var scheduledAt any
if body.Status == models.ArticleStatusDraft {
// Draft wins over any stale published_date in a full replacement payload.
} else if body.Status == models.ArticleStatusScheduled && body.PublishedAt != nil {
scheduledAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05")
} else if body.PublishedAt != nil {
if body.PublishedAt.After(time.Now().UTC()) {
scheduledAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05")
} else {
publishedAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05")
}
} else if body.Status == models.ArticleStatusPublished {
publishedAt = time.Now().UTC().Format("2006-01-02 15:04:05")
}
Expand All @@ -640,5 +665,6 @@ func ArticleToDBFields(body models.Article) []any {
strings.TrimSpace(body.FocusKeyword),
strings.TrimSpace(body.MetaDescription),
strings.TrimSpace(body.SEOTitle),
scheduledAt,
}
}
22 changes: 22 additions & 0 deletions server/internal/database/http_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,25 @@ func TestArticleInputToDBFields_PreservesBreakingNews(t *testing.T) {
t.Fatal("breaking_news field was not preserved")
}
}

func TestArticleInputToDBFields_StoresFuturePublishDateAsSchedule(t *testing.T) {
fields := ArticleInputToDBFields(models.ArticleInput{
Title: "Tomorrow's story",
Content: "Body",
Status: models.ArticleStatusPublished,
PublishedDate: "2030-05-06T14:30:00Z",
})

publishedDateFieldIndex := 6
if fields[publishedDateFieldIndex] != nil {
t.Fatalf("pub_date = %v, want nil until schedule is due", fields[publishedDateFieldIndex])
}
scheduledDateFieldIndex := 18
got, ok := fields[scheduledDateFieldIndex].(string)
if !ok {
t.Fatalf("scheduled_pub_date field has type %T, want string", fields[scheduledDateFieldIndex])
}
if got != "2030-05-06 14:30:00" {
t.Fatalf("scheduled_pub_date = %q, want scheduled timestamp", got)
}
}
Loading
Loading