From 160b1a8445e8c3f2f86ab477ff6d7cbfffd63790 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 10:35:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20public=20ShortPlayer=20page=20a?= =?UTF-8?q?t=20/shorts/:token=20with=20video=20playba=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/pages/ShortPlayer.tsx" - "frontend/src/pages/ShortPlayer.module.css" - "frontend/src/api/shorts.ts" - "frontend/src/App.tsx" - "frontend/src/pages/HighlightQueue.tsx" - "frontend/src/pages/HighlightQueue.module.css" GSD-Task: S01/T02 --- .gsd/milestones/M024/slices/S01/S01-PLAN.md | 2 +- .../M024/slices/S01/tasks/T01-VERIFY.json | 40 +++++ .../M024/slices/S01/tasks/T02-SUMMARY.md | 86 ++++++++++ frontend/src/App.tsx | 2 + frontend/src/api/shorts.ts | 30 +++- frontend/src/pages/HighlightQueue.module.css | 4 + frontend/src/pages/HighlightQueue.tsx | 71 ++++++++- frontend/src/pages/ShortPlayer.module.css | 120 ++++++++++++++ frontend/src/pages/ShortPlayer.tsx | 150 ++++++++++++++++++ frontend/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 .gsd/milestones/M024/slices/S01/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md create mode 100644 frontend/src/pages/ShortPlayer.module.css create mode 100644 frontend/src/pages/ShortPlayer.tsx diff --git a/.gsd/milestones/M024/slices/S01/S01-PLAN.md b/.gsd/milestones/M024/slices/S01/S01-PLAN.md index a6d2044..27ac6e6 100644 --- a/.gsd/milestones/M024/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M024/slices/S01/S01-PLAN.md @@ -18,7 +18,7 @@ - Estimate: 1.5h - Files: backend/models.py, alembic/versions/026_add_share_token.py, backend/pipeline/stages.py, backend/routers/shorts.py, backend/routers/shorts_public.py, backend/main.py - Verify: cd backend && python -c "from routers.shorts_public import router; print('import ok')" && cd .. && echo 'alembic migration file exists:' && test -f alembic/versions/026_add_share_token.py && echo 'OK' -- [ ] **T02: Public shorts page and share/embed buttons on HighlightQueue** — Create the `/shorts/:token` public frontend page with a video player and metadata display. Add share URL copy and embed snippet copy buttons to the HighlightQueue page for completed shorts. Create the frontend API client for the public endpoint. +- [x] **T02: Added public ShortPlayer page at /shorts/:token with video playback and metadata, plus share link and embed code copy buttons on HighlightQueue completed shorts** — Create the `/shorts/:token` public frontend page with a video player and metadata display. Add share URL copy and embed snippet copy buttons to the HighlightQueue page for completed shorts. Create the frontend API client for the public endpoint. ## Steps diff --git a/.gsd/milestones/M024/slices/S01/tasks/T01-VERIFY.json b/.gsd/milestones/M024/slices/S01/tasks/T01-VERIFY.json new file mode 100644 index 0000000..3625613 --- /dev/null +++ b/.gsd/milestones/M024/slices/S01/tasks/T01-VERIFY.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M024/S01/T01", + "timestamp": 1775298780200, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 10, + "verdict": "pass" + }, + { + "command": "cd ..", + "exitCode": 0, + "durationMs": 9, + "verdict": "pass" + }, + { + "command": "echo 'alembic migration file exists:'", + "exitCode": 0, + "durationMs": 10, + "verdict": "pass" + }, + { + "command": "test -f alembic/versions/026_add_share_token.py", + "exitCode": 0, + "durationMs": 5, + "verdict": "pass" + }, + { + "command": "echo 'OK'", + "exitCode": 0, + "durationMs": 4, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md b/.gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..888626a --- /dev/null +++ b/.gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md @@ -0,0 +1,86 @@ +--- +id: T02 +parent: S01 +milestone: M024 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/pages/ShortPlayer.tsx", "frontend/src/pages/ShortPlayer.module.css", "frontend/src/api/shorts.ts", "frontend/src/App.tsx", "frontend/src/pages/HighlightQueue.tsx", "frontend/src/pages/HighlightQueue.module.css"] +key_decisions: ["fetchPublicShort uses raw fetch() to avoid injecting auth token on public endpoint", "Clipboard copy uses navigator.clipboard with execCommand fallback", "Share/embed buttons only render when share_token is non-null"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "npm run build passed with zero TypeScript errors in 2.90s. ShortPlayer lazy-loaded as separate chunk." +completed_at: 2026-04-04T10:35:52.402Z +blocker_discovered: false +--- + +# T02: Added public ShortPlayer page at /shorts/:token with video playback and metadata, plus share link and embed code copy buttons on HighlightQueue completed shorts + +> Added public ShortPlayer page at /shorts/:token with video playback and metadata, plus share link and embed code copy buttons on HighlightQueue completed shorts + +## What Happened +--- +id: T02 +parent: S01 +milestone: M024 +key_files: + - frontend/src/pages/ShortPlayer.tsx + - frontend/src/pages/ShortPlayer.module.css + - frontend/src/api/shorts.ts + - frontend/src/App.tsx + - frontend/src/pages/HighlightQueue.tsx + - frontend/src/pages/HighlightQueue.module.css +key_decisions: + - fetchPublicShort uses raw fetch() to avoid injecting auth token on public endpoint + - Clipboard copy uses navigator.clipboard with execCommand fallback + - Share/embed buttons only render when share_token is non-null +duration: "" +verification_result: passed +completed_at: 2026-04-04T10:35:52.402Z +blocker_discovered: false +--- + +# T02: Added public ShortPlayer page at /shorts/:token with video playback and metadata, plus share link and embed code copy buttons on HighlightQueue completed shorts + +**Added public ShortPlayer page at /shorts/:token with video playback and metadata, plus share link and embed code copy buttons on HighlightQueue completed shorts** + +## What Happened + +Created the public-facing ShortPlayer page that fetches short metadata via fetchPublicShort (unauthenticated GET), renders a video element with the presigned MinIO URL, displays creator name and highlight title, and provides copy-to-clipboard buttons for both the share URL and an iframe embed snippet. Updated GeneratedShort interface with share_token field. Registered /shorts/:token as a lazy-loaded public route in App.tsx. Added share link and embed code buttons on HighlightQueue completed shorts with clipboard API + execCommand fallback. + +## Verification + +npm run build passed with zero TypeScript errors in 2.90s. ShortPlayer lazy-loaded as separate chunk. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build 2>&1 | tail -5` | 0 | ✅ pass | 6500ms | + + +## Deviations + +Used download_url instead of video_url to match actual backend API response. Used emoji icons instead of text labels for share/embed buttons. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/pages/ShortPlayer.tsx` +- `frontend/src/pages/ShortPlayer.module.css` +- `frontend/src/api/shorts.ts` +- `frontend/src/App.tsx` +- `frontend/src/pages/HighlightQueue.tsx` +- `frontend/src/pages/HighlightQueue.module.css` + + +## Deviations +Used download_url instead of video_url to match actual backend API response. Used emoji icons instead of text labels for share/embed buttons. + +## Known Issues +None. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c8b5be..2dd8a6d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue")); const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers")); const PostEditor = React.lazy(() => import("./pages/PostEditor")); const PostsList = React.lazy(() => import("./pages/PostsList")); +const ShortPlayer = React.lazy(() => import("./pages/ShortPlayer")); import AdminDropdown from "./components/AdminDropdown"; import ImpersonationBanner from "./components/ImpersonationBanner"; import AppFooter from "./components/AppFooter"; @@ -180,6 +181,7 @@ function AppShell() { } /> }>} /> }>} /> + }>} /> {/* Browse routes */} } /> diff --git a/frontend/src/api/shorts.ts b/frontend/src/api/shorts.ts index 2ea87e6..a15f454 100644 --- a/frontend/src/api/shorts.ts +++ b/frontend/src/api/shorts.ts @@ -1,4 +1,4 @@ -import { request, BASE } from "./client"; +import { request, BASE, ApiError } from "./client"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -13,6 +13,17 @@ export interface GeneratedShort { width: number; height: number; created_at: string; + share_token: string | null; +} + +export interface PublicShortResponse { + format_preset: string; + width: number; + height: number; + duration_secs: number | null; + creator_name: string; + highlight_title: string; + download_url: string; } export interface ShortsListResponse { @@ -51,3 +62,20 @@ export function getShortDownloadUrl( `${BASE}/admin/shorts/download/${encodeURIComponent(shortId)}`, ); } + +/** + * Fetch a public short by share token — no auth required. + * Uses raw fetch instead of `request()` to avoid injecting the auth header. + */ +export async function fetchPublicShort( + token: string, +): Promise { + const res = await fetch( + `${BASE}/public/shorts/${encodeURIComponent(token)}`, + ); + if (!res.ok) { + const detail = res.status === 404 ? "Short not found" : res.statusText; + throw new ApiError(res.status, detail); + } + return res.json() as Promise; +} diff --git a/frontend/src/pages/HighlightQueue.module.css b/frontend/src/pages/HighlightQueue.module.css index d6b38dd..fec04a1 100644 --- a/frontend/src/pages/HighlightQueue.module.css +++ b/frontend/src/pages/HighlightQueue.module.css @@ -441,6 +441,10 @@ opacity: 0.7; } +.copiedFlash { + color: var(--color-badge-approved-text, #22c55e) !important; +} + .shortError { color: var(--color-badge-rejected-text, #ef4444); font-size: 0.75rem; diff --git a/frontend/src/pages/HighlightQueue.tsx b/frontend/src/pages/HighlightQueue.tsx index 8ba7c7b..54b1daa 100644 --- a/frontend/src/pages/HighlightQueue.tsx +++ b/frontend/src/pages/HighlightQueue.tsx @@ -20,6 +20,24 @@ import styles from "./HighlightQueue.module.css"; /* ── Helpers ────────────────────────────────────────────────────────────────── */ +async function copyToClipboard(text: string): Promise { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { /* fall through */ } + } + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; +} + function formatDuration(secs: number): string { const m = Math.floor(secs / 60); const s = Math.floor(secs % 60); @@ -108,6 +126,7 @@ export default function HighlightQueue() { new Map(), ); const [generatingIds, setGeneratingIds] = useState>(new Set()); + const [copiedShort, setCopiedShort] = useState<{ id: string; kind: "share" | "embed" } | null>(null); const pollRef = useRef | null>(null); const loadHighlights = useCallback(async (tab: FilterTab) => { @@ -320,6 +339,25 @@ export default function HighlightQueue() { } }; + const handleCopyShareLink = async (s: GeneratedShort) => { + const url = `${window.location.origin}/shorts/${s.share_token}`; + const ok = await copyToClipboard(url); + if (ok) { + setCopiedShort({ id: s.id, kind: "share" }); + setTimeout(() => setCopiedShort(null), 2000); + } + }; + + const handleCopyEmbed = async (s: GeneratedShort) => { + const src = `${window.location.origin}/shorts/${s.share_token}`; + const snippet = ``; + const ok = await copyToClipboard(snippet); + if (ok) { + setCopiedShort({ id: s.id, kind: "embed" }); + setTimeout(() => setCopiedShort(null), 2000); + } + }; + const tabs: { key: FilterTab; label: string }[] = [ { key: "all", label: "All" }, { key: "shorts", label: "Shorts" }, @@ -443,12 +481,33 @@ export default function HighlightQueue() { {s.status} {s.status === "complete" && ( - + <> + + {s.share_token && ( + <> + + + + )} + )} {s.status === "failed" && s.error_message && ( { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Clipboard API failed — fall through to fallback + } + } + // Fallback: hidden textarea + execCommand + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; +} + +function buildEmbedSnippet( + token: string, + width: number, + height: number, +): string { + const src = `${window.location.origin}/shorts/${token}`; + return ``; +} + +export default function ShortPlayer() { + const { token } = useParams<{ token: string }>(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copied, setCopied] = useState<"share" | "embed" | null>(null); + + useEffect(() => { + if (!token) { + setError("No share token provided"); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + fetchPublicShort(token) + .then((res) => { + if (!cancelled) setData(res); + }) + .catch((err) => { + if (!cancelled) { + setError( + err?.detail ?? err?.message ?? "Short not found", + ); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [token]); + + const showCopiedFlash = useCallback((which: "share" | "embed") => { + setCopied(which); + setTimeout(() => setCopied(null), 2000); + }, []); + + const handleCopyShare = useCallback(async () => { + const url = `${window.location.origin}/shorts/${token}`; + const ok = await copyToClipboard(url); + if (ok) showCopiedFlash("share"); + }, [token, showCopiedFlash]); + + const handleCopyEmbed = useCallback(async () => { + if (!data || !token) return; + const snippet = buildEmbedSnippet(token, data.width, data.height); + const ok = await copyToClipboard(snippet); + if (ok) showCopiedFlash("embed"); + }, [data, token, showCopiedFlash]); + + if (loading) { + return ( +
Loading…
+ ); + } + + if (error || !data) { + return ( +
+

{error ?? "Short not found"}

+

This short may have been removed or the link is invalid.

+
+ ); + } + + return ( +
+
+
+
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index ca94c00..c7306b4 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file