diff --git a/scripts/generate-content-index.ts b/scripts/generate-content-index.ts index fca1cd8..ecd161d 100644 --- a/scripts/generate-content-index.ts +++ b/scripts/generate-content-index.ts @@ -32,7 +32,9 @@ async function readJsonFile(absolutePath: string): Promise { } } -async function readJsonObjectFile(absolutePath: string): Promise> { +async function readJsonObjectFile( + absolutePath: string, +): Promise> { const parsed = await readJsonFile(absolutePath); if (!isRecord(parsed)) { throw new Error(`Invalid JSON at ${absolutePath}: expected object.`); @@ -46,7 +48,9 @@ function assertValidVideoChannel( channel: unknown, ): void { if (!isRecord(channel)) { - throw new Error(`Invalid video.json at ${absolutePath}: expected video.channel object.`); + throw new Error( + `Invalid video.json at ${absolutePath}: expected video.channel object.`, + ); } if (channel.platform !== platform) { @@ -83,11 +87,15 @@ async function assertValidVideoJson( // Invariant: video.json must match its on-disk location under content/platforms/{platform}/videos/{videoId}. if (raw.videoId !== videoId) { - throw new Error(`Invalid video.json at ${absolutePath}: expected videoId '${videoId}'.`); + throw new Error( + `Invalid video.json at ${absolutePath}: expected videoId '${videoId}'.`, + ); } if (typeof raw.videoUrl !== 'string' || !raw.videoUrl) { - throw new Error(`Invalid video.json at ${absolutePath}: expected non-empty videoUrl.`); + throw new Error( + `Invalid video.json at ${absolutePath}: expected non-empty videoUrl.`, + ); } if (typeof raw.title !== 'string' || !raw.title) { @@ -119,6 +127,25 @@ async function listVideoIds(platform: Platform): Promise { return out.sort(); } +async function listFileNames(absoluteDir: string): Promise> { + // 1 readdir per video directory (faster than multiple per-file stats). + try { + const entries = await readdir(absoluteDir, { withFileTypes: true }); + const out = new Set(); + for (const entry of entries) { + if (entry.isFile()) out.add(entry.name); + } + return out; + } catch (error) { + process.stderr.write( + `Warning: Failed to read content directory ${absoluteDir}: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + return new Set(); + } +} + function relFromGenerated(absolutePath: string): string { const rel = path.relative(OUT_DIR, absolutePath); return rel.startsWith('.') ? rel : `./${rel}`; @@ -140,7 +167,13 @@ async function main(): Promise { const validations: Validation[] = await Promise.all( videoEntries.map(async ({ platform, videoId }) => { - const videoJsonPath = path.join(CONTENT_ROOT, platform, 'videos', videoId, 'video.json'); + const videoJsonPath = path.join( + CONTENT_ROOT, + platform, + 'videos', + videoId, + 'video.json', + ); try { await assertValidVideoJson(videoJsonPath, platform, videoId); return { ok: true as const, platform, videoId }; @@ -150,10 +183,13 @@ async function main(): Promise { }), ); - const invalidEntries = validations.filter((v): v is Extract => !v.ok); + const invalidEntries = validations.filter( + (v): v is Extract => !v.ok, + ); if (invalidEntries.length > 0) { const messages = invalidEntries.map((entry) => { - const detail = entry.error instanceof Error ? entry.error.message : String(entry.error); + const detail = + entry.error instanceof Error ? entry.error.message : String(entry.error); return `[${entry.platform}:${entry.videoId}] ${detail}`; }); @@ -166,7 +202,9 @@ async function main(): Promise { } } - const validEntries = validations.filter((v): v is Extract => v.ok); + const validEntries = validations.filter( + (v): v is Extract => v.ok, + ); const contentImports: string[] = [ '// AUTO-GENERATED FILE. DO NOT EDIT.', @@ -175,7 +213,9 @@ async function main(): Promise { "import type { VideoContent } from '../types';", '', ]; - const contentMapLines: string[] = ['export const VIDEO_CONTENT: Record = {']; + const contentMapLines: string[] = [ + 'export const VIDEO_CONTENT: Record = {', + ]; const reportImports: string[] = [ '// AUTO-GENERATED FILE. DO NOT EDIT.', @@ -192,29 +232,44 @@ async function main(): Promise { const ident = safeIdent(`${platform}_${videoId}`); const base = path.join(CONTENT_ROOT, platform, 'videos', videoId); - const videoPath = relFromGenerated(path.join(base, 'video.json')); - const commentsPath = relFromGenerated(path.join(base, 'comments.json')); - const analyticsPath = relFromGenerated(path.join(base, 'analytics.json')); - const reportPath = relFromGenerated(path.join(base, 'report.mdx')); + const files = await listFileNames(base); + + const videoAbs = path.join(base, 'video.json'); + const commentsAbs = path.join(base, 'comments.json'); + const analyticsAbs = path.join(base, 'analytics.json'); + const reportAbs = path.join(base, 'report.mdx'); + + const hasComments = files.has('comments.json'); + const hasAnalytics = files.has('analytics.json'); + const hasReport = files.has('report.mdx'); + + const videoPath = relFromGenerated(videoAbs); + const commentsPath = relFromGenerated(commentsAbs); + const analyticsPath = relFromGenerated(analyticsAbs); + const reportPath = relFromGenerated(reportAbs); contentImports.push( `import ${ident}_video from '${videoPath}';`, - `import ${ident}_comments from '${commentsPath}';`, - `import ${ident}_analytics from '${analyticsPath}';`, + ...(hasComments ? [`import ${ident}_comments from '${commentsPath}';`] : []), + ...(hasAnalytics ? [`import ${ident}_analytics from '${analyticsPath}';`] : []), '', ); - reportImports.push(`import ${ident}_report from '${reportPath}';`, ''); + if (hasReport) { + reportImports.push(`import ${ident}_report from '${reportPath}';`, ''); + } contentMapLines.push( ` '${platform}:${videoId}': {`, ` video: ${ident}_video as VideoContent['video'],`, - ` comments: ${ident}_comments,`, - ` analytics: ${ident}_analytics,`, + ` comments: ${hasComments ? `${ident}_comments` : 'undefined'},`, + ` analytics: ${hasAnalytics ? `${ident}_analytics` : 'undefined'},`, ' },', ); - reportMapLines.push(` '${platform}:${videoId}': ${ident}_report,`); + reportMapLines.push( + ` '${platform}:${videoId}': ${hasReport ? `${ident}_report` : 'undefined'},`, + ); } contentMapLines.push('};', ''); diff --git a/src/App.tsx b/src/App.tsx index c7f5000..443db23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { Route, Routes } from 'react-router-dom'; import { Layout } from './components/Layout'; +import { JobsPage } from './pages/JobsPage'; import { LibraryPage } from './pages/LibraryPage'; import { OnboardingPage } from './pages/OnboardingPage'; import { VideoAnalyticsPage } from './pages/VideoAnalyticsPage'; @@ -10,6 +11,7 @@ export function App(): JSX.Element { }> } /> + } /> } /> } /> diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 5f7a0d8..fb855ea 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -37,6 +37,14 @@ export function NavBar(): JSX.Element { > Library + + Jobs +
diff --git a/src/content/types.ts b/src/content/types.ts index ab6540a..d8eecfe 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -52,8 +52,14 @@ export type CommentAnalytics = { gentleCritiques: string[]; }; +/** + * Build-time content for a video. + * + * `comments` and `analytics` are optional to support partial ingestion states + * (e.g. comments are captured, but analysis/report haven't run yet). + */ export type VideoContent = { video: VideoMetadata; - comments: CommentRecord[]; - analytics: CommentAnalytics; + comments?: CommentRecord[]; + analytics?: CommentAnalytics; }; diff --git a/src/lib/localLibrary.ts b/src/lib/localLibrary.ts new file mode 100644 index 0000000..f057cb4 --- /dev/null +++ b/src/lib/localLibrary.ts @@ -0,0 +1,178 @@ +import type { Platform } from '../content/types'; +import { fetchYouTubeOEmbed } from './youtube'; + +const STORAGE_KEY = 'constructive_local_library_v1'; + +export type LocalLibraryVideo = { + platform: Platform; + videoId: string; + videoUrl: string; + addedAtMs: number; + updatedAtMs: number; + title?: string; + channelTitle?: string; + thumbnailUrl?: string; +}; + +function nowMs(): number { + return Date.now(); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function parseLocalLibrary(raw: string | null): LocalLibraryVideo[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + + return parsed + .map((entry) => { + const rec = asRecord(entry); + if (!rec) return null; + const platform = rec.platform === 'youtube' ? 'youtube' : null; + const videoId = typeof rec.videoId === 'string' ? rec.videoId : null; + const videoUrl = typeof rec.videoUrl === 'string' ? rec.videoUrl : null; + const addedAtMs = typeof rec.addedAtMs === 'number' ? rec.addedAtMs : null; + const updatedAtMs = typeof rec.updatedAtMs === 'number' ? rec.updatedAtMs : null; + if (!platform || !videoId || !videoUrl) return null; + if (addedAtMs === null || updatedAtMs === null) return null; + if (!Number.isFinite(addedAtMs) || !Number.isFinite(updatedAtMs)) return null; + + const out: LocalLibraryVideo = { + platform, + videoId, + videoUrl, + addedAtMs, + updatedAtMs, + }; + + if (typeof rec.title === 'string') out.title = rec.title; + if (typeof rec.channelTitle === 'string') out.channelTitle = rec.channelTitle; + if (typeof rec.thumbnailUrl === 'string') out.thumbnailUrl = rec.thumbnailUrl; + + return out; + }) + .filter((v): v is LocalLibraryVideo => v !== null); + } catch { + return []; + } +} + +function getStorage(): Storage | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function loadLibrary(): LocalLibraryVideo[] { + const storage = getStorage(); + if (!storage) return []; + try { + return parseLocalLibrary(storage.getItem(STORAGE_KEY)); + } catch { + // Corrupted or inaccessible local state is treated as empty to keep the app usable. + if (import.meta.env.DEV) { + console.warn('[localLibrary] Failed to load local library; treating as empty.'); + } + return []; + } +} + +function persistLibrary(entries: LocalLibraryVideo[]): void { + const storage = getStorage(); + if (!storage) return; + try { + storage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(-200))); + } catch { + // ignore: local library is best-effort + if (import.meta.env.DEV) { + console.warn('[localLibrary] Failed to persist local library; changes not saved.'); + } + } +} + +export function listLocalLibraryVideos(): LocalLibraryVideo[] { + return loadLibrary().sort((a, b) => b.updatedAtMs - a.updatedAtMs); +} + +export function upsertLocalLibraryVideo({ + platform, + videoId, + videoUrl, +}: { + platform: Platform; + videoId: string; + videoUrl: string; +}): void { + const entries = loadLibrary(); + const now = nowMs(); + const idx = entries.findIndex((e) => e.platform === platform && e.videoId === videoId); + const existing = idx >= 0 ? entries[idx] : null; + + const next: LocalLibraryVideo = { + platform, + videoId, + videoUrl, + addedAtMs: existing?.addedAtMs ?? now, + updatedAtMs: now, + title: existing?.title, + channelTitle: existing?.channelTitle, + thumbnailUrl: existing?.thumbnailUrl, + }; + + if (idx >= 0) { + entries[idx] = next; + } else { + entries.push(next); + } + + persistLibrary(entries); +} + +export function removeLocalLibraryVideo(platform: Platform, videoId: string): void { + persistLibrary( + loadLibrary().filter((e) => !(e.platform === platform && e.videoId === videoId)), + ); +} + +export async function hydrateLocalLibraryVideoMetadata( + platform: Platform, + videoId: string, + signal?: AbortSignal, +): Promise { + if (platform !== 'youtube') return false; + + const entries = loadLibrary(); + const idx = entries.findIndex((e) => e.platform === platform && e.videoId === videoId); + const existing = idx >= 0 ? entries[idx] : null; + if (!existing) return false; + if (existing.title && existing.channelTitle && existing.thumbnailUrl) return false; + + const oembed = await fetchYouTubeOEmbed(videoId, signal); + if (!oembed) return false; + + const fresh = loadLibrary(); + const freshIdx = fresh.findIndex( + (e) => e.platform === platform && e.videoId === videoId, + ); + if (freshIdx < 0) return false; + + fresh[freshIdx] = { + ...fresh[freshIdx], + title: fresh[freshIdx].title ?? oembed.title, + channelTitle: fresh[freshIdx].channelTitle ?? oembed.channelTitle, + thumbnailUrl: fresh[freshIdx].thumbnailUrl ?? oembed.thumbnailUrl, + updatedAtMs: nowMs(), + }; + + persistLibrary(fresh); + + return true; +} diff --git a/src/lib/youtube.ts b/src/lib/youtube.ts index f83dec2..1315949 100644 --- a/src/lib/youtube.ts +++ b/src/lib/youtube.ts @@ -26,3 +26,33 @@ export function extractYouTubeVideoId(input: string): string | null { return null; } + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +export async function fetchYouTubeOEmbed( + videoId: string, + signal?: AbortSignal, +): Promise<{ title: string; channelTitle: string; thumbnailUrl?: string } | null> { + try { + const url = `https://www.youtube.com/oembed?url=${encodeURIComponent( + `https://www.youtube.com/watch?v=${videoId}`, + )}&format=json`; + const res = await fetch(url, { signal }); + if (!res.ok) return null; + const parsed = (await res.json()) as unknown; + const raw = asRecord(parsed) ?? {}; + + const title = typeof raw.title === 'string' ? raw.title : ''; + const channelTitle = typeof raw.author_name === 'string' ? raw.author_name : ''; + const thumbnailUrl = + typeof raw.thumbnail_url === 'string' ? raw.thumbnail_url : undefined; + + if (!title || !channelTitle) return null; + return { title, channelTitle, thumbnailUrl }; + } catch { + return null; + } +} diff --git a/src/main.tsx b/src/main.tsx index 3e3851d..8155b41 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,32 @@ import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; import './styles.css'; +const redirectPath = new URLSearchParams(window.location.search).get('_redirect'); +if (redirectPath) { + const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + let decoded = redirectPath; + try { + decoded = decodeURIComponent(redirectPath); + } catch { + // ignore malformed encoding + } + + const target = decoded.startsWith('/') ? decoded : `/${decoded}`; + + const match = target.match(/^([^?#]*)(.*)$/); + const pathPart = match?.[1] ?? target; + const segments = pathPart.split('/').filter(Boolean); + const hasTraversal = segments.some((segment) => segment === '.' || segment === '..'); + + const hasScheme = pathPart.includes('://'); + const isProtocolRelative = pathPart.startsWith('//'); + const isSafePath = /^\/[0-9A-Za-z_\-./]*$/.test(pathPart); + + if (!isProtocolRelative && !hasScheme && !hasTraversal && isSafePath) { + window.history.replaceState(null, '', `${base}${target}`); + } +} + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/JobsPage.tsx b/src/pages/JobsPage.tsx new file mode 100644 index 0000000..d21aded --- /dev/null +++ b/src/pages/JobsPage.tsx @@ -0,0 +1,273 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; + +import { getVideoContent } from '../content/content'; +import type { Platform } from '../content/types'; +import { + hydrateLocalLibraryVideoMetadata, + listLocalLibraryVideos, + removeLocalLibraryVideo, + upsertLocalLibraryVideo, +} from '../lib/localLibrary'; +import { extractYouTubeVideoId } from '../lib/youtube'; +import { Button } from '../components/ui/Button'; + +type JobStage = + | { kind: 'scraping'; label: string; detail: string } + | { kind: 'analysis'; label: string; detail: string } + | { kind: 'ready'; label: string; detail: string }; + +function getJobStage(platform: Platform, videoId: string): JobStage { + const content = getVideoContent(platform, videoId); + if (!content) { + return { + kind: 'scraping', + label: 'Scraping', + detail: 'Queued to capture comments.', + }; + } + + if (content.analytics) { + return { + kind: 'ready', + label: 'Analysis ready', + detail: 'Open the report when you’re ready.', + }; + } + + if (content.comments) { + return { + kind: 'analysis', + label: 'Comments captured', + detail: 'Analysis is pending the next playbook run.', + }; + } + + return { + kind: 'scraping', + label: 'Scraping', + detail: 'Capturing comments (this can take a bit).', + }; +} + +const FOCUSED_JOB_STYLE = { + borderColor: 'rgba(106, 169, 255, 0.8)', + boxShadow: '0 0 0 1px rgba(106, 169, 255, 0.35)', +} as const; + +export function JobsPage(): JSX.Element { + const [searchParams, setSearchParams] = useSearchParams(); + const focused = searchParams.get('video'); + + // oEmbed hydration is performed in a batched effect (cancellable + de-duplicated). + const oembedInFlight = useRef(new Set()); + + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + + const [videos, setVideos] = useState(() => listLocalLibraryVideos()); + + useEffect(() => { + let active = true; + + // One controller per effect run. Cleanup aborts the whole batch on unmount/list changes. + const controller = new AbortController(); + const missing = videos.filter( + (v) => v.platform === 'youtube' && (!v.title || !v.channelTitle), + ); + const batch = missing + .filter((v) => !oembedInFlight.current.has(`${v.platform}:${v.videoId}`)) + .slice(0, 5); + if (batch.length === 0) return () => controller.abort(); + + for (const v of batch) { + oembedInFlight.current.add(`${v.platform}:${v.videoId}`); + } + + void Promise.all( + batch.map((v) => + hydrateLocalLibraryVideoMetadata( + v.platform, + v.videoId, + controller.signal, + ).finally(() => { + oembedInFlight.current.delete(`${v.platform}:${v.videoId}`); + }), + ), + ).then((results) => { + if (!active) return; + if (results.some(Boolean)) setVideos(listLocalLibraryVideos()); + }); + + return () => { + active = false; + controller.abort(); + }; + }, [videos]); + + function addByInput(): void { + setError(null); + const videoId = extractYouTubeVideoId(input); + if (!videoId) { + setError('Paste a YouTube link or an 11-character video id.'); + return; + } + + const platform: Platform = 'youtube'; + const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; + upsertLocalLibraryVideo({ platform, videoId, videoUrl }); + setVideos(listLocalLibraryVideos()); + + const key = `${platform}:${videoId}`; + if (!oembedInFlight.current.has(key)) { + oembedInFlight.current.add(key); + void hydrateLocalLibraryVideoMetadata(platform, videoId) + .then((updated) => { + if (updated) setVideos(listLocalLibraryVideos()); + }) + .finally(() => { + oembedInFlight.current.delete(key); + }); + } + + setInput(''); + setSearchParams({ video: `${platform}:${videoId}` }); + } + + return ( +
+
+

Jobs

+

+ This browser keeps a local library of the videos you’ve requested. Ingestion and + analysis are idempotent (keyed by video id): comments get captured first, then + playbook runs fill in analytics + MDX reports. +

+
+ +
+

Add a YouTube video

+
+ setInput(e.target.value)} + placeholder="https://www.youtube.com/watch?v=..." + style={{ + flex: '1 1 340px', + minWidth: 240, + padding: '10px 12px', + borderRadius: 10, + border: '1px solid var(--border)', + background: 'rgba(255,255,255,0.04)', + color: 'var(--text)', + }} + /> + +
+ {error ? ( +
+ Heads up: {error} +
+ ) : null} +
+ +
+ {videos.length === 0 ? ( +
+

No jobs yet

+

+ Add a YouTube URL above and it’ll show up here. +

+
+ ) : ( + videos.map((video) => { + const stage = getJobStage(video.platform, video.videoId); + const isFocused = focused === `${video.platform}:${video.videoId}`; + const title = video.title ?? video.videoUrl; + const channel = video.channelTitle ?? 'YouTube'; + + return ( +
+
+ {video.thumbnailUrl ? ( + + ) : null} +
+
{title}
+
+ {channel} ·{' '} + + {video.videoId} + +
+
+ {stage.label}: {stage.detail} +
+
+
+ + Open on YouTube + + {stage.kind === 'ready' ? ( + + Open report + + ) : null} + +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 79e69f5..2cd0478 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -25,7 +25,8 @@ export function LibraryPage(): JSX.Element { return all.filter( (v) => - v.title.toLowerCase().includes(q) || v.channel.channelTitle.toLowerCase().includes(q), + v.title.toLowerCase().includes(q) || + v.channel.channelTitle.toLowerCase().includes(q), ); }, [query]); @@ -36,8 +37,8 @@ export function LibraryPage(): JSX.Element {

Pick content to analyze

- The “backend” for this MVP is just structured files in git. Each video has its own - metadata, comments snapshot, computed analytics, and a report in MDX. + The “backend” for this MVP is just structured files in git. Each video has its + own metadata, comments snapshot, computed analytics, and a report in MDX.

@@ -81,15 +82,21 @@ export function LibraryPage(): JSX.Element {
{channelVideos.map((video) => { const content = getVideoContent(video.platform, video.videoId); - const commentCount = content?.analytics.commentCount ?? content?.comments.length; + const commentCount = + content?.analytics?.commentCount ?? content?.comments?.length; return ( -
+
{ setError(null); - const unlocked = unlockVideo(`${video.platform}:${video.videoId}`); + const unlocked = unlockVideo( + `${video.platform}:${video.videoId}`, + ); if (!unlocked.ok) { event.preventDefault(); setError(unlocked.reason); diff --git a/src/pages/OnboardingPage.tsx b/src/pages/OnboardingPage.tsx index f224236..5b5d800 100644 --- a/src/pages/OnboardingPage.tsx +++ b/src/pages/OnboardingPage.tsx @@ -1,8 +1,13 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { listVideos } from '../content/content'; +import { getVideoContent, listVideos } from '../content/content'; +import type { Platform } from '../content/types'; import { unlockVideo } from '../lib/freemium'; +import { + hydrateLocalLibraryVideoMetadata, + upsertLocalLibraryVideo, +} from '../lib/localLibrary'; import { extractYouTubeVideoId } from '../lib/youtube'; import { VideoCard } from '../components/VideoCard'; import { Button } from '../components/ui/Button'; @@ -22,21 +27,18 @@ export function OnboardingPage(): JSX.Element { return; } - const known = videos.find((v) => v.videoId === videoId); - if (!known) { - setError( - 'This build only ships with a small demo library. Add a new video by running the ingestion playbook in the repo.', - ); - return; - } + const platform: Platform = 'youtube'; + const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; + upsertLocalLibraryVideo({ platform, videoId, videoUrl }); + void hydrateLocalLibraryVideoMetadata(platform, videoId); - const unlocked = unlockVideo(`youtube:${videoId}`); - if (!unlocked.ok) { - setError(unlocked.reason); + const content = getVideoContent(platform, videoId); + if (content?.analytics) { + navigate(`/video/${platform}/${videoId}`); return; } - navigate(`/video/youtube/${videoId}`); + navigate(`/jobs?video=${platform}:${videoId}`); } return ( @@ -44,16 +46,16 @@ export function OnboardingPage(): JSX.Element {

Comment analytics that protects your energy.

- Pick a video, pull the comments, and generate a creator-friendly report: what resonated, - what to clarify next time, and what to ignore. + Pick a video, pull the comments, and generate a creator-friendly report: what + resonated, what to clarify next time, and what to ignore.

Connect a platform

- This MVP is wired for YouTube (no API key required for ingestion). TikTok/Instagram are - treated as fast follows via the connector interfaces. + This MVP is wired for YouTube (no API key required for ingestion). + TikTok/Instagram are treated as fast follows via the connector interfaces.

@@ -66,7 +68,7 @@ export function OnboardingPage(): JSX.Element {

Analyze a YouTube video

- Paste a link to jump straight to analytics (works for the included demo videos). + Paste a link to add it to your library and start capture + analysis.

Analyze -
{error ? ( @@ -102,7 +104,7 @@ export function OnboardingPage(): JSX.Element { { setError(null); const unlocked = unlockVideo(`${video.platform}:${video.videoId}`); diff --git a/src/pages/VideoAnalyticsPage.tsx b/src/pages/VideoAnalyticsPage.tsx index 8b30782..216b0da 100644 --- a/src/pages/VideoAnalyticsPage.tsx +++ b/src/pages/VideoAnalyticsPage.tsx @@ -1,7 +1,7 @@ import { MDXProvider } from '@mdx-js/react'; import type { MDXComponents } from 'mdx/types'; import { useEffect, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { Bar, BarChart, @@ -51,15 +51,58 @@ export function VideoAnalyticsPage(): JSX.Element { if (!content) { return (
-

Video not found

+

Not ready yet

- This build only includes a small demo library. Add more videos by running the ingestion - playbook in the repo. + This video isn’t in this build yet. If you added it via the UI, check the Jobs + dashboard for capture + analysis status.

+
+ + Open Jobs + +
+
+ ); + } + + if (!content.analytics) { + const commentCount = content.comments?.length; + + return ( +
+
+

{content.video.title}

+

+ {content.video.channel.channelTitle} ·{' '} + + Open on YouTube + +

+
+ +
+

Analysis pending

+

+ {typeof commentCount === 'number' + ? `${commentCount.toLocaleString()} comments captured. Analytics + report will show up after the next playbook run.` + : 'This video has been requested but comments aren’t captured yet.'} +

+
+ + Open Jobs + +
+
); } + const analytics = content.analytics; + if (!unlocked) { const gate = canRunAnalysis(); @@ -109,7 +152,7 @@ export function VideoAnalyticsPage(): JSX.Element { ); } - const sentimentData = Object.entries(content.analytics.sentimentBreakdown).map( + const sentimentData = Object.entries(analytics.sentimentBreakdown).map( ([name, value]) => ({ name, value }), ); @@ -127,15 +170,15 @@ export function VideoAnalyticsPage(): JSX.Element {
-
{content.analytics.commentCount.toLocaleString()}
+
{analytics.commentCount.toLocaleString()}
Comments captured
-
{content.analytics.questionCount.toLocaleString()}
+
{analytics.questionCount.toLocaleString()}
Questions
-
{content.analytics.suggestionCount.toLocaleString()}
+
{analytics.suggestionCount.toLocaleString()}
Suggestions
@@ -172,8 +215,8 @@ export function VideoAnalyticsPage(): JSX.Element {

- This is a fast heuristic pass (lexicon + tone filter), not a fully-trained sentiment - model. + This is a fast heuristic pass (lexicon + tone filter), not a fully-trained + sentiment model.

@@ -181,9 +224,18 @@ export function VideoAnalyticsPage(): JSX.Element {

Top themes

- + - + - +
@@ -205,16 +261,18 @@ export function VideoAnalyticsPage(): JSX.Element {

We don’t need to re-traumatize creators to find signal.

-
+
Toxic / harsh - {content.analytics.toxicCount.toLocaleString()} + {analytics.toxicCount.toLocaleString()}
Shown quotes - {content.analytics.safeQuotes.length} + {analytics.safeQuotes.length}
@@ -224,7 +282,7 @@ export function VideoAnalyticsPage(): JSX.Element {

Constructive takeaways

    - {content.analytics.gentleCritiques.slice(0, 6).map((item) => ( + {analytics.gentleCritiques.slice(0, 6).map((item) => (
  • {item}
  • ))}
@@ -232,8 +290,10 @@ export function VideoAnalyticsPage(): JSX.Element {

Notable quotes (safe)

-
- {content.analytics.safeQuotes.slice(0, 6).map((quote) => ( +
+ {analytics.safeQuotes.slice(0, 6).map((quote) => (
“{quote}”
@@ -246,7 +306,8 @@ export function VideoAnalyticsPage(): JSX.Element {

Playbook report (MDX)

- This section is rendered from an MDX artifact generated by the analytics playbook. + This section is rendered from an MDX artifact generated by the analytics + playbook.

diff --git a/vite.config.ts b/vite.config.ts index cb101ff..e968afe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,17 +1,70 @@ import mdx from '@mdx-js/rollup'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { defineConfig, type Plugin, type ResolvedConfig } from 'vite'; + +function spa404Redirect(): Plugin { + let config: ResolvedConfig | null = null; + + return { + name: 'constructive-github-pages-404', + apply: 'build', + configResolved(resolvedConfig) { + config = resolvedConfig; + }, + async closeBundle() { + if (!config) return; + + const outDir = config.build.outDir; + const file = path.join(outDir, '404.html'); + + const base = config.base; + const normalizedBase = base.endsWith('/') ? base : `${base}/`; + + const html = ` + + + + + Constructive + + + + + +`; + + await writeFile(file, html, 'utf8'); + }, + }; +} export default defineConfig(() => { const repo = process.env.GITHUB_REPOSITORY?.split('/')[1]; const isPages = process.env.GITHUB_PAGES === 'true'; + // GitHub user/org Pages repositories are conventionally named `*.github.io` and are hosted at `/`. + const isUserOrOrgPages = Boolean(repo && repo.endsWith('.github.io')); + const base = isPages && repo && !isUserOrOrgPages ? `/${repo}/` : '/'; + return { - base: isPages && repo ? `/${repo}/` : '/', + base, plugins: [ // MDX needs to run before React. - mdx(), + mdx({ providerImportSource: '@mdx-js/react' }), react(), + spa404Redirect(), ], }; });