feat(content-strategy): ✨ Introduce content analysis scanning module with new pages, navigation updates, and utilities
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
3117a299e6
commit
0ed1b14022
10 changed files with 733 additions and 16 deletions
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<Route path="/press" element={withErrorBoundary(<PressPage />)} />
|
||||
<Route path="/health" element={withErrorBoundary(<FileHealthPage />)} />
|
||||
<Route path="/merch" element={withErrorBoundary(<MerchPage />)} />
|
||||
<Route path="/view/*" element={withErrorBoundary(<DocumentViewerPage />)} />
|
||||
<Route path="/readiness" element={withErrorBoundary(<ContentReadinessPage />)} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/** @jsxImportSource react */
|
||||
|
||||
import type { JSX } from 'react';
|
||||
import { Link } from '@lilith/ui-router';
|
||||
import styled from '@lilith/ui-styled-components';
|
||||
import { viewerUrl } from '@/utils/content-paths';
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #818cf8;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
`;
|
||||
|
||||
interface FileLinkProps {
|
||||
path: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileLink({ path, className }: FileLinkProps): JSX.Element {
|
||||
return (
|
||||
<StyledLink to={viewerUrl(path)} className={className} title={path}>
|
||||
{path}
|
||||
</StyledLink>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
|
||||
const groupMap = useMemo(() => {
|
||||
const map = new Map<string, Group>();
|
||||
for (const g of groups) {
|
||||
map.set(g.key, g);
|
||||
}
|
||||
return map;
|
||||
}, [groups]);
|
||||
|
||||
const grouped = useMemo<GroupedItems[]>(() => {
|
||||
const byGroup = new Map<string, ContentItem[]>();
|
||||
|
||||
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 (
|
||||
<PageWrapper>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.25rem' }}>
|
||||
Content Readiness
|
||||
</h1>
|
||||
<p style={{ color: '#888', fontSize: '0.875rem', marginBottom: '2rem' }}>
|
||||
File type coverage matrix · {contentItems.length} topics across{' '}
|
||||
{grouped.length} groups · {fileTypeColumns.length} deliverable types
|
||||
</p>
|
||||
|
||||
<FilterBar>
|
||||
<FilterButton
|
||||
$active={!activeGroup}
|
||||
$color="#fff"
|
||||
onClick={() => setActiveGroup(null)}
|
||||
>
|
||||
All ({grouped.length})
|
||||
</FilterButton>
|
||||
{grouped.map((g) => (
|
||||
<FilterButton
|
||||
key={g.group.key}
|
||||
$active={activeGroup === g.group.key}
|
||||
$color={g.group.color}
|
||||
onClick={() =>
|
||||
setActiveGroup(activeGroup === g.group.key ? null : g.group.key)
|
||||
}
|
||||
>
|
||||
{g.group.label} ({g.items.length})
|
||||
</FilterButton>
|
||||
))}
|
||||
</FilterBar>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<tr>
|
||||
<ThTopic style={{ width: '40%' }}>Topic</ThTopic>
|
||||
{fileTypeColumns.map((ft) => (
|
||||
<ThRotated key={ft.key}>
|
||||
<RotatedLabel>{ft.label}</RotatedLabel>
|
||||
</ThRotated>
|
||||
))}
|
||||
</tr>
|
||||
</Thead>
|
||||
<tbody>
|
||||
{filteredGroups.map((g) => {
|
||||
const pct =
|
||||
g.total > 0 ? Math.round((g.completed / g.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<GroupRows
|
||||
key={g.group.key}
|
||||
group={g}
|
||||
pct={pct}
|
||||
fileTypeColumns={fileTypeColumns}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupRowsProps {
|
||||
group: GroupedItems;
|
||||
pct: number;
|
||||
fileTypeColumns: ContentFileTypeMeta[];
|
||||
}
|
||||
|
||||
function GroupRows({ group, pct, fileTypeColumns }: GroupRowsProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<GroupHeaderRow>
|
||||
<GroupHeaderCell colSpan={fileTypeColumns.length + 1}>
|
||||
<GroupProgress>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: group.group.color,
|
||||
display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>{group.group.label}</span>
|
||||
<ProgressBar $pct={pct} $color={group.group.color} />
|
||||
<ProgressText>
|
||||
{group.completed}/{group.total} complete
|
||||
</ProgressText>
|
||||
</GroupProgress>
|
||||
</GroupHeaderCell>
|
||||
</GroupHeaderRow>
|
||||
{group.items.map((item, idx) => {
|
||||
const isDone = item.status === 'draft' || item.status === 'seeded';
|
||||
const fileSet = new Set<ContentFileType>(item.files);
|
||||
|
||||
return (
|
||||
<ItemRow key={`${item.group}-${idx}`}>
|
||||
<TopicCell>
|
||||
<StatusBadgeWrapper>
|
||||
<Badge
|
||||
color={STATUS_COLORS[item.status] ?? '#888'}
|
||||
size="sm"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</StatusBadgeWrapper>
|
||||
{item.topic}
|
||||
</TopicCell>
|
||||
{fileTypeColumns.map((ft) => (
|
||||
<DotCell key={ft.key}>
|
||||
{fileSet.has(ft.key) ? (
|
||||
isDone ? (
|
||||
<FilledDot title={`${ft.label}: done`}>●</FilledDot>
|
||||
) : (
|
||||
<EmptyDot title={`${ft.label}: planned`}>○</EmptyDot>
|
||||
)
|
||||
) : null}
|
||||
</DotCell>
|
||||
))}
|
||||
</ItemRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ViewerState>('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 (
|
||||
<PageWrapper>
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbLink to="/">Home</BreadcrumbLink>
|
||||
{segments.map((segment, i) => {
|
||||
const isLast = i === segments.length - 1;
|
||||
return (
|
||||
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
{isLast ? (
|
||||
<BreadcrumbCurrent>{segment}</BreadcrumbCurrent>
|
||||
) : (
|
||||
<BreadcrumbCurrent>{segment}</BreadcrumbCurrent>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
|
||||
{state === 'loading' && (
|
||||
<LoadingContainer>Loading document...</LoadingContainer>
|
||||
)}
|
||||
|
||||
{state === 'not_found' && (
|
||||
<NotFoundContainer>
|
||||
<NotFoundTitle>Not Written Yet</NotFoundTitle>
|
||||
<NotFoundPath>{contentPath}</NotFoundPath>
|
||||
<p style={{ color: '#888', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
|
||||
This content file has not been created yet. It exists as a planned item in the content calendar.
|
||||
</p>
|
||||
<BackLink to="/health">Back to File Health</BackLink>
|
||||
</NotFoundContainer>
|
||||
)}
|
||||
|
||||
{state === 'loaded' && (
|
||||
<MarkdownContainer>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
|
||||
</MarkdownContainer>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'}
|
||||
/>
|
||||
<FileLink>{file.name}</FileLink>
|
||||
<FileLink path={file.path} />
|
||||
</div>
|
||||
<FileMeta>
|
||||
<Badge
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Badge } from '@lilith/ui-primitives';
|
|||
import { MetricCard, DashboardLayout, DashboardWidget } from '@lilith/ui-analytics';
|
||||
import { useContentData } from '@/hooks/useContentData';
|
||||
|
||||
import { FileLink } from '@/components/FileLink';
|
||||
const PageWrapper = styled.div`
|
||||
padding: 1.5rem;
|
||||
`;
|
||||
|
|
@ -263,9 +264,11 @@ export function FileHealthPage(): JSX.Element {
|
|||
<Badge variant="primary" size="sm">{row.period}</Badge>
|
||||
</span>
|
||||
<span>{row.title}</span>
|
||||
<FilePath title={row.file ?? 'No file assigned'}>
|
||||
{row.file ?? '\u2014'}
|
||||
</FilePath>
|
||||
{row.file ? (
|
||||
<FileLink path={row.file} />
|
||||
) : (
|
||||
<FilePath>\u2014</FilePath>
|
||||
)}
|
||||
<StatusDot $color={statusColor(row.status)}>
|
||||
{statusLabel(row.status)}
|
||||
</StatusDot>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</Badge>
|
||||
<span>{item.title}</span>
|
||||
{item.file ? (<FileLink path={item.file} />) : (<span>{item.title}</span>)}
|
||||
{item.flex && (
|
||||
<span style={{ color: '#fbbf24', fontSize: '0.65rem', fontWeight: 600 }}>
|
||||
FLEX
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue