import { useEffect, useMemo, useState } from 'react' import { ChevronDown, ChevronUp, Copy, Loader2, Pencil, Play, RefreshCcw, Trash2 } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { Button, Card, Loader } from '../components/common' import { Header } from '../components/layout/Header' import { endpoints, getErrorMessage } from '../api/client' import { getDateLocale, t, tf } from '../lib/i18n' import type { ConversationMessage, SavedContentPlan } from '../types' interface SessionPreviewState { technical: ConversationMessage[] content: ConversationMessage[] } interface ChatSessionResponse { messages?: ConversationMessage[] } function buildPreviewMessages(messages: ConversationMessage[]): ConversationMessage[] { if (messages.length <= 4) { return messages } const firstAssistantIndex = messages.findIndex((message) => message.role === 'assistant') const firstAssistant = firstAssistantIndex >= 0 ? messages[firstAssistantIndex] : null const tail = messages.slice(-4) if (!firstAssistant) { return tail } const preview = [firstAssistant, ...tail] return preview.filter((message, index) => ( preview.findIndex((item) => item.role === message.role && item.content === message.content) === index )) } function PlanToggle({ checked, onToggle, }: { checked: boolean onToggle: () => void }) { return ( ) } function formatDate(value: string | null | undefined): string { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return value return new Intl.DateTimeFormat(getDateLocale(), { dateStyle: 'medium', timeStyle: 'short', }).format(date) } function formatRecurringSummary(plan: SavedContentPlan): string { const schedule = plan.schedule_config if (!schedule) { if (plan.repeat_frequency === 'daily') return t('Daily') if (plan.repeat_frequency === 'monthly') return t('Monthly') if (plan.repeat_frequency === 'weekly') return t('Weekly') return '—' } const weekdayLabels = [t('Sun'), t('Mon'), t('Tue'), t('Wed'), t('Thu'), t('Fri'), t('Sat')] const unitLabel = schedule.unit === 'month' ? t(schedule.interval === 1 ? 'month' : 'months') : schedule.unit === 'day' ? t(schedule.interval === 1 ? 'day' : 'days') : t(schedule.interval === 1 ? 'week' : 'weeks') const weekdayLabel = schedule.unit === 'week' && schedule.weekdays.length > 0 ? ` • ${schedule.weekdays.map((weekday) => weekdayLabels[weekday] || weekday).join(', ')}` : '' return `${t('Every')} ${schedule.interval > 1 ? `${schedule.interval} ` : ''}${unitLabel}${weekdayLabel} • ${String(schedule.hour).padStart(2, '0')}:00` } function formatRemainingBudget(plan: SavedContentPlan): string { if (!plan.schedule_counters) { return '—' } const parts = [ plan.schedule_counters.remaining_runs !== null ? `${plan.schedule_counters.remaining_runs} ${t('Runs Left')}` : '', plan.schedule_counters.remaining_posts !== null ? `${plan.schedule_counters.remaining_posts} ${t('Posts Left')}` : '', ].filter(Boolean) return parts.length > 0 ? parts.join(' • ') : t('Unlimited') } function getTopicTreeArticleCount(plan: SavedContentPlan): number { const pillars = plan.content_plan?.topic_tree?.pillars || [] const articleCount = pillars.reduce((total, pillar) => ( total + (pillar.clusters || []).reduce((clusterTotal, cluster) => clusterTotal + (cluster.articles || []).length, 0) ), 0) if (articleCount > 0) return articleCount const fromCounts = pillars.reduce((total, pillar) => ( total + (pillar.article_count || (pillar.clusters || []).reduce((ct, c) => ct + (c.article_count || 0), 0)) ), 0) if (fromCounts > 0) return fromCounts return plan.content_plan?.topic_tree?.total_articles || 0 } const NEW_FLOW_DRAFT_KEY = 'bc:content-new-flow-draft' const NEW_FLOW_MAX_AGE = 2 * 60 * 60 * 1000 function hasUnsavedNewFlowDraft(): boolean { try { const raw = sessionStorage.getItem(NEW_FLOW_DRAFT_KEY) if (!raw) return false const draft = JSON.parse(raw) as { timestamp?: number; config?: { postType?: string } } if (!draft.config?.postType) return false if (typeof draft.timestamp === 'number' && Date.now() - draft.timestamp > NEW_FLOW_MAX_AGE) return false return true } catch { return false } } export default function ContentPlansPage() { const navigate = useNavigate() const [plans, setPlans] = useState([]) const [loading, setLoading] = useState(true) const [expandedId, setExpandedId] = useState(null) const [busyAction, setBusyAction] = useState(null) const [error, setError] = useState(null) const [sessionPreviews, setSessionPreviews] = useState>({}) const [planTermNames, setPlanTermNames] = useState>({}) const [hasUnsavedDraft] = useState(() => hasUnsavedNewFlowDraft()) const [runningPlanId, setRunningPlanId] = useState(null) const [runError, setRunError] = useState(null) const handleRunPlan = async (plan: SavedContentPlan) => { if (!plan.post_type || !plan.reporter_id) { navigate(`/generate?planId=${plan.id}&intent=review&step=settings`) return } setRunningPlanId(plan.id) setRunError(null) try { await endpoints.sprintGenerate({ post_type: plan.post_type, taxonomy: plan.taxonomy_scope || undefined, term: plan.term_scope || undefined, reporter_id: plan.reporter_id, topic: plan.topic, keywords: plan.keywords, count: plan.post_count, length: plan.post_length, content_plan: plan.content_plan || undefined, content_answers: plan.content_answers || undefined, technical_summary: plan.technical_rules?.summary || undefined, technical_answers: plan.technical_rules?.rules || undefined, plan_id: plan.id, }) try { await endpoints.markContentPlanRun(plan.id) } catch { // Non-fatal: sprint is running, mark failed } navigate('/jobs') } catch (err: unknown) { const message = getErrorMessage(err, t('Failed to start the plan')) setRunError(message) setRunningPlanId(null) } } const loadPlans = async () => { setLoading(true) setError(null) try { const res = await endpoints.getContentPlans() const data = res.data as { items?: SavedContentPlan[] } setPlans(Array.isArray(data?.items) ? data.items : []) } catch (err) { setError(getErrorMessage(err, t('Unknown error'))) } finally { setLoading(false) } } useEffect(() => { void loadPlans() }, []) useEffect(() => { if (!expandedId || (sessionPreviews[expandedId] && planTermNames[expandedId] !== undefined)) { return } const plan = plans.find((item) => item.id === expandedId) if (!plan) { return } const normalizedTermScope = plan.term_scope && plan.term_scope !== '-' ? plan.term_scope : null void (async () => { try { const [technicalRes, contentRes] = await Promise.all([ plan.technical_session_id ? endpoints.getChatSession(plan.technical_session_id).catch(() => null) : Promise.resolve(null), plan.content_session_id ? endpoints.getChatSession(plan.content_session_id).catch(() => null) : Promise.resolve(null), ]) const nextTermNames = plan.taxonomy_scope && normalizedTermScope ? await endpoints .getTaxonomyTerms(plan.post_type, plan.taxonomy_scope) .then((res) => { const selectedSlugs = normalizedTermScope.split(',').filter(Boolean) return ((res.data as Array<{ slug: string; name: string }>) || []) .filter((term) => selectedSlugs.includes(term.slug)) .map((term) => term.name) }) .catch(() => normalizedTermScope.split(',').filter(Boolean)) : [] setSessionPreviews((current) => ({ ...current, [plan.id]: { technical: buildPreviewMessages(((technicalRes?.data as ChatSessionResponse | undefined)?.messages || [])), content: buildPreviewMessages(((contentRes?.data as ChatSessionResponse | undefined)?.messages || [])), }, })) setPlanTermNames((current) => ({ ...current, [plan.id]: nextTermNames, })) } catch { setSessionPreviews((current) => ({ ...current, [plan.id]: { technical: [], content: [], }, })) setPlanTermNames((current) => ({ ...current, [plan.id]: normalizedTermScope ? normalizedTermScope.split(',').filter(Boolean) : [], })) } })() }, [expandedId, planTermNames, plans, sessionPreviews]) const groupedPlans = useMemo(() => { return plans.map((plan) => ({ ...plan, keywordSummary: plan.keywords.length > 0 ? plan.keywords.slice(0, 4) : [], })) }, [plans]) const handleToggleActive = async (plan: SavedContentPlan) => { setBusyAction(`toggle-${plan.id}`) try { const res = await endpoints.toggleContentPlanActive(plan.id, !plan.is_active) const updated = res.data as SavedContentPlan setPlans((current) => current.map((item) => (item.id === updated.id ? updated : item))) } catch (err) { setError(getErrorMessage(err, t('Unknown error'))) } finally { setBusyAction(null) } } const handleDuplicate = async (planId: number) => { setBusyAction(`duplicate-${planId}`) try { await endpoints.duplicateContentPlan(planId) await loadPlans() } catch (err) { setError(getErrorMessage(err, t('Unknown error'))) } finally { setBusyAction(null) } } const handleDelete = async (planId: number) => { if (!window.confirm(t('Delete this content plan?'))) { return } setBusyAction(`delete-${planId}`) try { await endpoints.deleteContentPlan(planId) setPlans((current) => current.filter((item) => item.id !== planId)) if (expandedId === planId) { setExpandedId(null) } } catch (err) { setError(getErrorMessage(err, t('Unknown error'))) } finally { setBusyAction(null) } } return (

{t('Content Plans')}

{t('Create, run, review, duplicate, and monitor all of your reusable plans in one place.')}

{hasUnsavedDraft && ( )}
{error ? (
{error}
) : null} {runError ? (
{runError}
) : null} {loading ? (
) : groupedPlans.length === 0 ? (

{t('No content plans yet')}

{t('Start a new content generation and save its configuration to build your plan library.')}

{hasUnsavedDraft && ( )}
) : (
{groupedPlans.map((plan) => { const isExpanded = expandedId === plan.id const preview = sessionPreviews[plan.id] const selectedCategories = planTermNames[plan.id] || [] return (

{plan.name || t('Untitled Plan')}

{plan.reporter_name || t('No reporter')} {plan.generation_type === 'repeating' ? t('Repeating plan') : t('One-time plan')} {plan.content_plan?.research_enabled ? ( {t('Research enabled')} ) : null} {getTopicTreeArticleCount(plan) > 0 ? ( {tf('%d topic angles', getTopicTreeArticleCount(plan))} ) : null}
{plan.post_type} {tf('%d posts planned', plan.post_count)} {tf('Last run: %s', formatDate(plan.last_run_at))} {plan.generation_type === 'repeating' ? ( <> {tf('Next run: %s', formatDate(plan.next_run_at))} ) : null}
{plan.keywordSummary.length > 0 ? (
{plan.keywordSummary.map((keyword) => ( {keyword} ))}
) : null}
{plan.generation_type === 'repeating' ? ( ) : null} {plan.post_type && plan.reporter_id ? ( ) : ( )}
{isExpanded ? (

{t('Generation settings')}

{t('Category')}: {selectedCategories.length > 0 ? selectedCategories.join(', ') : '—'}
{t('Topic / Instructions')}: {plan.topic || '—'}
{t('Keywords')}: {plan.keywords.length > 0 ? plan.keywords.join(', ') : '—'}
{t('Post length')}: {t(plan.post_length === 'auto' ? 'AI decides' : (String(plan.post_length || 'medium').charAt(0).toUpperCase() + String(plan.post_length || 'medium').slice(1)))}
{t('Number of posts')}: {plan.post_count}
{t('Category distribution')}: {plan.count_per_category ? t('Per category') : t('Total')}
{plan.generation_type === 'repeating' ? ( <>
{t('Recurring schedule')}: {formatRecurringSummary(plan)}
{t('Remaining budget')}: {formatRemainingBudget(plan)}
) : null}

{t('Technical rules')}

{plan.technical_rules?.summary || '—'}

{t('Content Plan')}

{plan.summary || '—'}
{t('Research')}:{' '} {plan.content_plan?.research_enabled ? t('Enabled') : t('Disabled')}
{t('Topic Tree')}:{' '} {getTopicTreeArticleCount(plan) > 0 ? tf('%d angles planned', getTopicTreeArticleCount(plan)) : t('Not planned yet')}

{t('Saved chat preview')}

{t('Technical chat')}
{(preview?.technical || []).length > 0 ? preview.technical.map((message, index) => (
{message.role === 'assistant' ? t('Assistant') : t('You')}: {message.content}
)) :
}
{t('Content planning chat')}
{(preview?.content || []).length > 0 ? preview.content.map((message, index) => (
{message.role === 'assistant' ? t('Assistant') : t('You')}: {message.content}
)) :
}
) : null}
) })}
)}
) }