From 0ed1b14022c66547c8ec3e63eef75984adb148bd Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 28 Feb 2026 12:48:31 -0800 Subject: [PATCH] =?UTF-8?q?feat(content-strategy):=20=E2=9C=A8=20Introduce?= =?UTF-8?q?=20content=20analysis=20scanning=20module=20with=20new=20pages,?= =?UTF-8?q?=20navigation=20updates,=20and=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../frontend-dev/scripts/scan-files.ts | 15 +- .../content-strategy/frontend-dev/src/App.tsx | 4 + .../frontend-dev/src/components/FileLink.tsx | 34 ++ .../src/config/navigation.config.ts | 5 + .../src/pages/ContentReadinessPage.tsx | 370 ++++++++++++++++++ .../src/pages/DocumentViewerPage.tsx | 285 ++++++++++++++ .../frontend-dev/src/pages/DomainsPage.tsx | 8 +- .../frontend-dev/src/pages/FileHealthPage.tsx | 9 +- .../frontend-dev/src/pages/TimelinePage.tsx | 3 +- .../frontend-dev/src/utils/content-paths.ts | 16 + 10 files changed, 733 insertions(+), 16 deletions(-) create mode 100644 features/content-strategy/frontend-dev/src/components/FileLink.tsx create mode 100644 features/content-strategy/frontend-dev/src/pages/ContentReadinessPage.tsx create mode 100644 features/content-strategy/frontend-dev/src/pages/DocumentViewerPage.tsx create mode 100644 features/content-strategy/frontend-dev/src/utils/content-paths.ts diff --git a/features/content-strategy/frontend-dev/scripts/scan-files.ts b/features/content-strategy/frontend-dev/scripts/scan-files.ts index 021f0bd68..42e07941f 100644 --- a/features/content-strategy/frontend-dev/scripts/scan-files.ts +++ b/features/content-strategy/frontend-dev/scripts/scan-files.ts @@ -6,8 +6,14 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dataDir = resolve(__dirname, '../src/data'); +const contentDir = resolve(__dirname, '../../../../../docs/content'); const outputPath = resolve(dataDir, 'file-status.json'); +function resolveContentPath(filePath: string): string { + const stripped = filePath.replace(/^(\.\.\/)+/, ''); + return resolve(contentDir, stripped); +} + interface CalendarPeriod { id: string; items: Array<{ @@ -65,17 +71,15 @@ function main(): void { continue; } if (item.file && !files[item.file]) { - const absPath = resolve(dataDir, item.file); files[item.file] = { - exists: existsSync(absPath), + exists: existsSync(resolveContentPath(item.file)), source: 'calendar', period: period.id, }; } if (item.file2 && !files[item.file2]) { - const absPath = resolve(dataDir, item.file2); files[item.file2] = { - exists: existsSync(absPath), + exists: existsSync(resolveContentPath(item.file2)), source: 'calendar', period: period.id, }; @@ -88,9 +92,8 @@ function main(): void { for (const sub of domain.subsections) { for (const file of sub.files) { if (file.path && !files[file.path]) { - const absPath = resolve(dataDir, file.path); files[file.path] = { - exists: existsSync(absPath), + exists: existsSync(resolveContentPath(file.path)), source: 'domains', }; } diff --git a/features/content-strategy/frontend-dev/src/App.tsx b/features/content-strategy/frontend-dev/src/App.tsx index e34b90277..3f3356b47 100644 --- a/features/content-strategy/frontend-dev/src/App.tsx +++ b/features/content-strategy/frontend-dev/src/App.tsx @@ -20,6 +20,8 @@ import { ThreadInterplayPage } from './pages/ThreadInterplayPage'; import { StoryArcPage } from './pages/StoryArcPage'; import { FileHealthPage } from './pages/FileHealthPage'; import { MerchPage } from './pages/MerchPage'; +import { DocumentViewerPage } from './pages/DocumentViewerPage'; +import { ContentReadinessPage } from './pages/ContentReadinessPage'; logVersionBanner({ primaryColor: '#00ffff', secondaryColor: '#ff00ff' }); @@ -54,6 +56,8 @@ export function App(): JSX.Element { )} /> )} /> )} /> + )} /> + )} /> + {path} + + ); +} diff --git a/features/content-strategy/frontend-dev/src/config/navigation.config.ts b/features/content-strategy/frontend-dev/src/config/navigation.config.ts index 63d62c42d..99e88cd51 100644 --- a/features/content-strategy/frontend-dev/src/config/navigation.config.ts +++ b/features/content-strategy/frontend-dev/src/config/navigation.config.ts @@ -9,6 +9,11 @@ export const NAVIGATION_SECTIONS: NavSection[] = [ label: 'Dashboard', description: 'Content strategy metrics overview', }, + { + to: '/readiness', + label: 'Readiness', + description: 'File type coverage matrix by content topic', + }, ], }, { diff --git a/features/content-strategy/frontend-dev/src/pages/ContentReadinessPage.tsx b/features/content-strategy/frontend-dev/src/pages/ContentReadinessPage.tsx new file mode 100644 index 000000000..e80a14672 --- /dev/null +++ b/features/content-strategy/frontend-dev/src/pages/ContentReadinessPage.tsx @@ -0,0 +1,370 @@ +/** @jsxImportSource react */ + +import type { JSX } from 'react'; +import { useState, useMemo } from 'react'; +import styled from '@lilith/ui-styled-components'; +import { Badge } from '@lilith/ui-primitives'; +import { useContentData } from '@/hooks/useContentData'; +import type { ContentItem, ContentFileType, ContentFileTypeMeta, Group } from '@/types/content'; + +const PageWrapper = styled.div` + padding: 1.5rem; +`; + +const FilterBar = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +`; + +const FilterButton = styled.button<{ $active: boolean; $color: string }>` + padding: 0.4rem 1rem; + border-radius: 6px; + border: 1px solid ${(p) => (p.$active ? p.$color : '#444')}; + background: ${(p) => (p.$active ? `${p.$color}22` : 'transparent')}; + color: ${(p) => (p.$active ? p.$color : '#888')}; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: ${(p) => p.$color}; + color: ${(p) => p.$color}; + } +`; + +const TableContainer = styled.div` + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + overflow-x: auto; +`; + +const Table = styled.table` + width: 100%; + border-collapse: collapse; + min-width: 800px; +`; + +const Thead = styled.thead` + border-bottom: 2px solid #333; +`; + +const ThRotated = styled.th` + height: 120px; + width: 48px; + min-width: 48px; + padding: 0; + vertical-align: bottom; + text-align: center; +`; + +const RotatedLabel = styled.div` + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + font-size: 0.7rem; + font-weight: 600; + color: #888; + letter-spacing: 0.03em; + padding-bottom: 0.5rem; + white-space: nowrap; +`; + +const ThTopic = styled.th` + text-align: left; + padding: 0.75rem 1rem; + font-size: 0.7rem; + font-weight: 700; + color: #888; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +const GroupHeaderRow = styled.tr` + background: #242424; + border-top: 1px solid #444; +`; + +const GroupHeaderCell = styled.td` + padding: 0.6rem 1rem; + font-size: 0.85rem; + font-weight: 600; + color: #fff; +`; + +const GroupProgress = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; +`; + +const ProgressBar = styled.div<{ $pct: number; $color: string }>` + width: 80px; + height: 6px; + background: #333; + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; + + &::after { + content: ''; + display: block; + width: ${(p) => p.$pct}%; + height: 100%; + background: ${(p) => p.$color}; + border-radius: 3px; + transition: width 0.3s; + } +`; + +const ProgressText = styled.span` + font-size: 0.7rem; + font-family: 'JetBrains Mono', monospace; + color: #888; +`; + +const ItemRow = styled.tr` + border-bottom: 1px solid #222; + + &:hover { + background: #1e1e1e; + } +`; + +const TopicCell = styled.td` + padding: 0.4rem 1rem; + font-size: 0.8rem; + color: #e0e0e0; +`; + +const DotCell = styled.td` + text-align: center; + padding: 0.4rem 0; + font-size: 1rem; + line-height: 1; +`; + +const FilledDot = styled.span` + color: #34d399; +`; + +const EmptyDot = styled.span` + color: #555; +`; + +const StatusBadgeWrapper = styled.span` + margin-right: 0.5rem; +`; + +const STATUS_COLORS: Record = { + draft: '#818cf8', + seeded: '#fbbf24', + new: '#34d399', + blocked: '#f87171', +}; + +interface GroupedItems { + group: Group; + items: ContentItem[]; + completed: number; + total: number; +} + +export function ContentReadinessPage(): JSX.Element { + const { contentItems, groups, fileTypes } = useContentData(); + const [activeGroup, setActiveGroup] = useState(null); + + const groupMap = useMemo(() => { + const map = new Map(); + for (const g of groups) { + map.set(g.key, g); + } + return map; + }, [groups]); + + const grouped = useMemo(() => { + const byGroup = new Map(); + + for (const item of contentItems) { + const existing = byGroup.get(item.group); + if (existing) { + existing.push(item); + } else { + byGroup.set(item.group, [item]); + } + } + + const result: GroupedItems[] = []; + for (const group of groups) { + const items = byGroup.get(group.key); + if (!items) continue; + + const completed = items.filter( + (i) => i.status === 'draft' || i.status === 'seeded', + ).length; + + result.push({ + group, + items, + completed, + total: items.length, + }); + } + + // Add any groups from data that aren't in the groups list + for (const [key, items] of byGroup) { + if (!groupMap.has(key)) { + const completed = items.filter( + (i) => i.status === 'draft' || i.status === 'seeded', + ).length; + result.push({ + group: { key, label: key, color: '#888', week: '' }, + items, + completed, + total: items.length, + }); + } + } + + return result; + }, [contentItems, groups, groupMap]); + + const filteredGroups = useMemo(() => { + if (!activeGroup) return grouped; + return grouped.filter((g) => g.group.key === activeGroup); + }, [grouped, activeGroup]); + + const fileTypeColumns: ContentFileTypeMeta[] = fileTypes; + + return ( + +

+ Content Readiness +

+

+ File type coverage matrix · {contentItems.length} topics across{' '} + {grouped.length} groups · {fileTypeColumns.length} deliverable types +

+ + + setActiveGroup(null)} + > + All ({grouped.length}) + + {grouped.map((g) => ( + + setActiveGroup(activeGroup === g.group.key ? null : g.group.key) + } + > + {g.group.label} ({g.items.length}) + + ))} + + + + + + + Topic + {fileTypeColumns.map((ft) => ( + + {ft.label} + + ))} + + + + {filteredGroups.map((g) => { + const pct = + g.total > 0 ? Math.round((g.completed / g.total) * 100) : 0; + + return ( + + ); + })} + +
+
+
+ ); +} + +interface GroupRowsProps { + group: GroupedItems; + pct: number; + fileTypeColumns: ContentFileTypeMeta[]; +} + +function GroupRows({ group, pct, fileTypeColumns }: GroupRowsProps): JSX.Element { + return ( + <> + + + + + {group.group.label} + + + {group.completed}/{group.total} complete + + + + + {group.items.map((item, idx) => { + const isDone = item.status === 'draft' || item.status === 'seeded'; + const fileSet = new Set(item.files); + + return ( + + + + + {item.status} + + + {item.topic} + + {fileTypeColumns.map((ft) => ( + + {fileSet.has(ft.key) ? ( + isDone ? ( + + ) : ( + + ) + ) : null} + + ))} + + ); + })} + + ); +} diff --git a/features/content-strategy/frontend-dev/src/pages/DocumentViewerPage.tsx b/features/content-strategy/frontend-dev/src/pages/DocumentViewerPage.tsx new file mode 100644 index 000000000..698aa2803 --- /dev/null +++ b/features/content-strategy/frontend-dev/src/pages/DocumentViewerPage.tsx @@ -0,0 +1,285 @@ +/** @jsxImportSource react */ + +import type { JSX } from 'react'; +import { useState, useEffect } from 'react'; +import { useLocation, Link } from '@lilith/ui-router'; +import styled from '@lilith/ui-styled-components'; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { contentApiUrl } from '@/utils/content-paths'; + +const PageWrapper = styled.div` + padding: 1.5rem; + max-width: 900px; +`; + +const Breadcrumbs = styled.nav` + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 1.5rem; + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; +`; + +const BreadcrumbLink = styled(Link)` + color: #818cf8; + text-decoration: none; + + &:hover { + text-decoration: underline; + color: #a5b4fc; + } +`; + +const BreadcrumbSeparator = styled.span` + color: #555; +`; + +const BreadcrumbCurrent = styled.span` + color: #bbb; +`; + +const LoadingContainer = styled.div` + padding: 3rem; + text-align: center; + color: #888; + font-size: 0.9rem; +`; + +const NotFoundContainer = styled.div` + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + padding: 3rem; + text-align: center; +`; + +const NotFoundTitle = styled.h2` + font-size: 1.25rem; + font-weight: 600; + color: #f87171; + margin-bottom: 0.75rem; +`; + +const NotFoundPath = styled.code` + display: block; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + color: #888; + margin-bottom: 1.5rem; +`; + +const BackLink = styled(Link)` + color: #818cf8; + text-decoration: none; + font-size: 0.875rem; + + &:hover { + text-decoration: underline; + } +`; + +const MarkdownContainer = styled.div` + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + padding: 2rem; + color: #e0e0e0; + line-height: 1.7; + + h1 { + font-size: 1.75rem; + font-weight: 700; + color: #fff; + margin: 0 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #333; + } + + h2 { + font-size: 1.35rem; + font-weight: 600; + color: #fff; + margin: 1.75rem 0 0.75rem; + } + + h3 { + font-size: 1.1rem; + font-weight: 600; + color: #ddd; + margin: 1.5rem 0 0.5rem; + } + + p { + margin: 0.75rem 0; + } + + a { + color: #818cf8; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + ul, + ol { + padding-left: 1.5rem; + margin: 0.75rem 0; + } + + li { + margin: 0.35rem 0; + } + + blockquote { + border-left: 3px solid #818cf8; + margin: 1rem 0; + padding: 0.5rem 1rem; + color: #bbb; + background: #242424; + border-radius: 0 6px 6px 0; + } + + code { + font-family: 'JetBrains Mono', monospace; + font-size: 0.85em; + background: #2a2a2a; + padding: 0.15rem 0.4rem; + border-radius: 4px; + color: #c084fc; + } + + pre { + background: #0d0d0d; + border: 1px solid #333; + border-radius: 8px; + padding: 1rem; + overflow-x: auto; + margin: 1rem 0; + + code { + background: none; + padding: 0; + color: #e0e0e0; + } + } + + table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + } + + th, + td { + border: 1px solid #333; + padding: 0.5rem 0.75rem; + text-align: left; + font-size: 0.85rem; + } + + th { + background: #242424; + font-weight: 600; + color: #fff; + } + + hr { + border: none; + border-top: 1px solid #333; + margin: 1.5rem 0; + } + + img { + max-width: 100%; + border-radius: 8px; + } + + strong { + color: #fff; + } + + em { + color: #ccc; + } +`; + +type ViewerState = 'loading' | 'loaded' | 'not_found'; + +export function DocumentViewerPage(): JSX.Element { + const location = useLocation(); + const contentPath = location.pathname.replace(/^\/view\//, ''); + + const [state, setState] = useState('loading'); + const [markdown, setMarkdown] = useState(''); + + useEffect(() => { + setState('loading'); + setMarkdown(''); + + fetch(contentApiUrl(contentPath)) + .then((res) => { + if (!res.ok) { + setState('not_found'); + return; + } + return res.text(); + }) + .then((text) => { + if (text !== undefined) { + setMarkdown(text); + setState('loaded'); + } + }) + .catch(() => { + setState('not_found'); + }); + }, [contentPath]); + + const segments = contentPath.split('/').filter(Boolean); + + return ( + + + Home + {segments.map((segment, i) => { + const isLast = i === segments.length - 1; + return ( + + / + {isLast ? ( + {segment} + ) : ( + {segment} + )} + + ); + })} + + + {state === 'loading' && ( + Loading document... + )} + + {state === 'not_found' && ( + + Not Written Yet + {contentPath} +

+ This content file has not been created yet. It exists as a planned item in the content calendar. +

+ Back to File Health +
+ )} + + {state === 'loaded' && ( + + {markdown} + + )} +
+ ); +} diff --git a/features/content-strategy/frontend-dev/src/pages/DomainsPage.tsx b/features/content-strategy/frontend-dev/src/pages/DomainsPage.tsx index 850b5be97..17c5286d8 100644 --- a/features/content-strategy/frontend-dev/src/pages/DomainsPage.tsx +++ b/features/content-strategy/frontend-dev/src/pages/DomainsPage.tsx @@ -7,6 +7,7 @@ import { Badge } from '@lilith/ui-primitives'; import { BarChart } from '@lilith/ui-charts'; import { useContentData } from '@/hooks/useContentData'; import type { DomainTree, FileStatusManifest } from '@/types/content'; +import { FileLink } from '@/components/FileLink'; const PageWrapper = styled.div` padding: 1.5rem; @@ -107,11 +108,6 @@ const FileRow = styled.div` } `; -const FileLink = styled.span` - font-family: 'JetBrains Mono', monospace; - font-size: 0.75rem; - color: #bbb; -`; const FileMeta = styled.div` display: flex; @@ -185,7 +181,7 @@ function DomainSection({ domain, defaultOpen, fileStatusManifest }: DomainSectio $exists={exists} title={exists ? 'File exists' : 'File missing'} /> - {file.name} + {row.period} {row.title} - - {row.file ?? '\u2014'} - + {row.file ? ( + + ) : ( + \u2014 + )} {statusLabel(row.status)} diff --git a/features/content-strategy/frontend-dev/src/pages/TimelinePage.tsx b/features/content-strategy/frontend-dev/src/pages/TimelinePage.tsx index 64bc1aac5..6f9ae6fe4 100644 --- a/features/content-strategy/frontend-dev/src/pages/TimelinePage.tsx +++ b/features/content-strategy/frontend-dev/src/pages/TimelinePage.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import styled from '@lilith/ui-styled-components'; import { Badge } from '@lilith/ui-primitives'; import { useContentData } from '@/hooks/useContentData'; +import { FileLink } from '@/components/FileLink'; const PageWrapper = styled.div` padding: 1.5rem; @@ -334,7 +335,7 @@ export function TimelinePage(): JSX.Element { > {item.stream} - {item.title} + {item.file ? () : ({item.title})} {item.flex && ( FLEX diff --git a/features/content-strategy/frontend-dev/src/utils/content-paths.ts b/features/content-strategy/frontend-dev/src/utils/content-paths.ts new file mode 100644 index 000000000..1bb0bfdbd --- /dev/null +++ b/features/content-strategy/frontend-dev/src/utils/content-paths.ts @@ -0,0 +1,16 @@ +/** + * Normalizes raw content file paths from data (e.g. "../owned-media/blog/foo.md") + * into clean paths usable for routing and API calls. + */ + +export function normalizeContentPath(rawPath: string): string { + return rawPath.replace(/^(\.\.\/)+/, ''); +} + +export function viewerUrl(rawPath: string): string { + return `/view/${normalizeContentPath(rawPath)}`; +} + +export function contentApiUrl(path: string): string { + return `/api/content/${path}`; +}