From e802222781d7d46d63a36dde34d45c34a0e7246f Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 10 Sep 2026 01:37:38 -0400 Subject: [PATCH 1/2] Move a story between the article list and the rail A developing story and the article that answers it were separate pieces of typing: an editor tracking a story in the rail retyped the headline into the editor, and one who had already filed the article retyped it into the rail. Both directions now carry the headline and blurb across. The article list gets a row action that posts the title and excerpt as a developing story, and the rail gets one that opens the new-article editor seeded from the story. Keeping the title identical across the hop is what lets the homepage link the two, since the rail matches on the slug the title derives. The copy is one-way on purpose. The rail is keyed on the title, so a story keeps saying what it said even if the article is later retitled or never filed at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhFBibhRZkEZzW3qJrAMy5 --- frontend/src/pages/articleView.tsx | 51 +++++++++++++++++++- frontend/src/pages/developingStoriesView.tsx | 22 ++++++++- frontend/src/pages/editArticleView.test.tsx | 10 ++++ frontend/src/pages/editArticleView.tsx | 9 ++-- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/articleView.tsx b/frontend/src/pages/articleView.tsx index 9044699..bf93cd7 100644 --- a/frontend/src/pages/articleView.tsx +++ b/frontend/src/pages/articleView.tsx @@ -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" @@ -11,6 +11,7 @@ type ArticleStatus = "Published" | "Scheduled" | "Draft" | "Archived" type ArticleItem = { id: string title: string + excerpt: string authors: string status: ArticleStatus date: string @@ -23,6 +24,7 @@ type ArticleItem = { type ApiArticle = { id: number title: string + excerpt?: string slug: string status: string published_date?: string @@ -211,6 +213,8 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article const [deleteError, setDeleteError] = useState(null) const [deletingArticleId, setDeletingArticleId] = useState(null) const [copiedArticleId, setCopiedArticleId] = useState(null) + const [promotingArticleId, setPromotingArticleId] = useState(null) + const [promotedArticleId, setPromotedArticleId] = useState(null) useEffect(() => { writeSessionJSON(uiStateKey, { @@ -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) @@ -616,6 +621,39 @@ 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 ?? "" }), + }) + // useApiFetch already explains a 403 with the admin-only dialog. + if (response.status === 403) { + return + } + 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?`) @@ -966,6 +1004,17 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article {copiedArticleId === item.id ? : } )} + {activeTab !== "trash" && ( + + )} )} +