Skip to content
Open
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
47 changes: 46 additions & 1 deletion frontend/src/pages/articleView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { ExternalLink, Search, Pencil, Plus, Trash2, X, ChevronFirst, ChevronLast, ChevronLeft, ChevronRight, Undo2, Copy, Check } from "lucide-react"
import { ExternalLink, Search, Pencil, Plus, Trash2, X, ChevronFirst, ChevronLast, ChevronLeft, ChevronRight, Undo2, Copy, Check, TrendingUp } from "lucide-react"
import { Link, useNavigate } from "react-router-dom"
import { articleUrl } from "../auth/urls"
import { useApiFetch } from "../hooks/useApiFetch"
Expand All @@ -11,6 +11,7 @@ type ArticleStatus = "Published" | "Scheduled" | "Draft" | "Archived"
type ArticleItem = {
id: string
title: string
excerpt: string
authors: string
status: ArticleStatus
date: string
Expand All @@ -23,6 +24,7 @@ type ArticleItem = {
type ApiArticle = {
id: number
title: string
excerpt?: string
slug: string
status: string
published_date?: string
Expand Down Expand Up @@ -211,6 +213,8 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deletingArticleId, setDeletingArticleId] = useState<string | null>(null)
const [copiedArticleId, setCopiedArticleId] = useState<string | null>(null)
const [promotingArticleId, setPromotingArticleId] = useState<string | null>(null)
const [promotedArticleId, setPromotedArticleId] = useState<string | null>(null)

useEffect(() => {
writeSessionJSON(uiStateKey, {
Expand Down Expand Up @@ -483,6 +487,7 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
const items = (payload.articles ?? []).map((item) => ({
id: String(item.id),
title: item.title,
excerpt: item.excerpt ?? "",
authors: (item.authors ?? [])
.map((author) => (author.name ?? "").trim())
.filter((name) => name.length > 0)
Expand Down Expand Up @@ -616,6 +621,35 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
setDeleteError("Could not copy the link. Your browser blocked clipboard access.")
}

// The developing story is a copy, not a reference: the rail is keyed on the
// title, so it keeps saying what it said even if the article is retitled or
// never published.
const addToDevelopingStories = async (item: ArticleItem) => {
if (promotingArticleId) return

setDeleteError(null)
setPromotingArticleId(item.id)
try {
const response = await apiFetch("/v1/developing-stories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: item.title, description: item.excerpt ?? "" }),
})
if (response.status === 409) {
throw new Error(`"${item.title}" is already a developing story.`)
}
if (!response.ok) {
throw new Error(`Could not add to developing stories (${response.status})`)
}
setPromotedArticleId(item.id)
setTimeout(() => setPromotedArticleId((current) => (current === item.id ? null : current)), 1500)
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Could not add to developing stories.")
} finally {
setPromotingArticleId(null)
}
}

const deleteArticle = async (item: ArticleItem) => {
if (!item.slug || deletingArticleId) return
const shouldDelete = window.confirm(`Move "${item.title}" to trash?`)
Expand Down Expand Up @@ -966,6 +1000,17 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
{copiedArticleId === item.id ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
)}
{activeTab !== "trash" && (
<button
className="p-1.5 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={promotingArticleId === item.id}
onClick={() => void addToDevelopingStories(item)}
title="Add to developing stories"
type="button"
>
{promotedArticleId === item.id ? <Check className="w-4 h-4" /> : <TrendingUp className="w-4 h-4" />}
</button>
)}
<button
className="p-1.5 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!item.slug}
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/pages/developingStoriesView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import type { FormEvent } from "react"
import { Plus, Trash2, RefreshCw, Pencil, Check, X } from "lucide-react"
import { Plus, Trash2, RefreshCw, Pencil, Check, X, FileText } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { useApiFetch } from "../hooks/useApiFetch"

type DevelopingStory = {
Expand Down Expand Up @@ -35,6 +36,7 @@ async function readErrorMessage(res: Response, fallback: string) {

function DevelopingStoriesView() {
const apiFetch = useApiFetch()
const navigate = useNavigate()
const [stories, setStories] = useState<DevelopingStory[]>([])
const [newStoryTitle, setNewStoryTitle] = useState("")
const [newStoryDescription, setNewStoryDescription] = useState("")
Expand Down Expand Up @@ -121,6 +123,15 @@ function DevelopingStoriesView() {
}
}

// Hands the story to the article editor as a starting point. Keeping the
// title identical is what later lets the homepage rail link the story to the
// article, since the rail matches on the slug derived from the title.
const writeArticle = (story: DevelopingStory) => {
const params = new URLSearchParams({ title: story.title })
if (story.description) params.set("excerpt", story.description)
navigate(`/articles/new?${params.toString()}`)
}

const deleteStory = async (title: string) => {
if (!confirm(`Delete developing story "${title}"?`)) return

Expand Down Expand Up @@ -288,6 +299,15 @@ function DevelopingStoriesView() {
<Pencil className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={() => writeArticle(story)}
className="p-1.5 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Write the article"
disabled={isSaving}
>
<FileText className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => deleteStory(story.title)}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/pages/editArticleView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ describe("EditArticleView autosave", () => {
vi.useRealTimers()
})

it("seeds a new article from the developing story that sent the editor here", async () => {
await renderEditor(
"/articles/new?title=Title%20IX%20Coordinator%20departs%20suddenly&excerpt=Blaze%20Bowers%20departs%20the%20office.",
"/articles/new",
)

expect(screen.getByLabelText("Title")).toHaveValue("Title IX Coordinator departs suddenly")
expect(screen.getByLabelText("Excerpt")).toHaveValue("Blaze Bowers departs the office.")
})

it("does not publish a draft when the editor only selects Publish now", async () => {
const user = await renderEditor()

Expand Down
9 changes: 6 additions & 3 deletions frontend/src/pages/editArticleView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { KeyboardEvent } from "react"
import { ArrowLeft, Save, Image, Search, X, Copy, Check, RefreshCw, Plus } from "lucide-react"
import { useNavigate, useParams } from "react-router-dom"
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
import { useApiFetch } from "../hooks/useApiFetch"
import { articleUrl } from "../auth/urls"
import TrixEditor from "../components/TrixEditor"
Expand Down Expand Up @@ -270,6 +270,9 @@
const navigate = useNavigate()
const apiFetch = useApiFetch()
const { id: rawID, slug: rawSlug } = useParams<{ id?: string; slug: string }>()
// Set when the editor arrived from a developing story, which hands over the
// headline and blurb it was already carrying.
const [searchParams] = useSearchParams()
const slug = useMemo(() => (rawSlug ? decodeURIComponent(rawSlug) : ""), [rawSlug])
const articleID = useMemo(() => (rawID && /^\d+$/.test(rawID) ? rawID : ""), [rawID])
const articleQuery = articleID ? `?id=${encodeURIComponent(articleID)}` : ""
Expand Down Expand Up @@ -303,8 +306,8 @@
const [lockedBy, setLockedBy] = useState<string | null>(null)
const [lockChecking, setLockChecking] = useState(false)

const [title, setTitle] = useState("")
const [excerpt, setExcerpt] = useState("")
const [title, setTitle] = useState(() => (isNew ? searchParams.get("title") ?? "" : ""))
const [excerpt, setExcerpt] = useState(() => (isNew ? searchParams.get("excerpt") ?? "" : ""))
const excerptRef = useRef<HTMLTextAreaElement>(null)

useEffect(() => {
Expand Down Expand Up @@ -935,7 +938,7 @@
}, AUTOSAVE_DELAY_MS)

return () => window.clearTimeout(timer)
}, [articleApiPath, articleID, articleSnapshot, isAutoSaving, isLoading, isNew, isSaving, lockedBy, selectedAuthorIds, selectedCategorySlugs])

Check warning on line 941 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"
Expand Down
6 changes: 3 additions & 3 deletions server/internal/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier,
mux.Handle("POST /v1/polls/{id}/options", authMW(handlers.PostPollRecordOption(conn)))
mux.Handle("PATCH /v1/polls/{id}/options/{option_id}", authMW(handlers.PatchPollRecordOption(conn)))
mux.Handle("DELETE /v1/polls/{id}/options/{option_id}", authMW(handlers.DeletePollRecordOption(conn)))
mux.Handle("POST /v1/developing-stories", authMW(adminOnly(handlers.PostDevelopingStory(conn))))
mux.Handle("PUT /v1/developing-stories", authMW(adminOnly(handlers.PutDevelopingStory(conn))))
mux.Handle("DELETE /v1/developing-stories", authMW(adminOnly(handlers.DeleteDevelopingStory(conn))))
mux.Handle("POST /v1/developing-stories", authMW(handlers.PostDevelopingStory(conn)))
mux.Handle("PUT /v1/developing-stories", authMW(handlers.PutDevelopingStory(conn)))
mux.Handle("DELETE /v1/developing-stories", authMW(handlers.DeleteDevelopingStory(conn)))
mux.Handle("PATCH /v1/settings/site", authMW(adminOnly(handlers.PatchSiteSettings(conn))))
mux.Handle("PATCH /v1/settings/seo", authMW(adminOnly(handlers.PatchSEOSettings(conn))))
mux.Handle("PATCH /v1/settings/breaking-news", authMW(adminOnly(handlers.PatchBreakingNews(conn))))
Expand Down
Loading