feat: Added public ShortPlayer page at /shorts/:token with video playba…

- "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
This commit is contained in:
jlightner 2026-04-04 10:35:56 +00:00
parent 5f4b960dc1
commit 160b1a8445
10 changed files with 498 additions and 9 deletions

View file

@ -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

View file

@ -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"
}
]
}

View file

@ -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.

View file

@ -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() {
<Route path="/techniques/:slug" element={<TechniquePage />} />
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
<Route path="/chat" element={<Suspense fallback={<LoadingFallback />}><ChatPage /></Suspense>} />
<Route path="/shorts/:token" element={<Suspense fallback={<LoadingFallback />}><ShortPlayer /></Suspense>} />
{/* Browse routes */}
<Route path="/creators" element={<CreatorsBrowse />} />

View file

@ -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<PublicShortResponse> {
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<PublicShortResponse>;
}

View file

@ -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;

View file

@ -20,6 +20,24 @@ import styles from "./HighlightQueue.module.css";
/* ── Helpers ────────────────────────────────────────────────────────────────── */
async function copyToClipboard(text: string): Promise<boolean> {
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<Set<string>>(new Set());
const [copiedShort, setCopiedShort] = useState<{ id: string; kind: "share" | "embed" } | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 = `<iframe src="${src}" width="${s.width}" height="${s.height}" frameborder="0" allowfullscreen></iframe>`;
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}
</span>
{s.status === "complete" && (
<button
className={styles.downloadLink}
onClick={() => handleDownload(s.id)}
>
</button>
<>
<button
className={styles.downloadLink}
onClick={() => handleDownload(s.id)}
title="Download"
>
</button>
{s.share_token && (
<>
<button
className={`${styles.downloadLink} ${copiedShort?.id === s.id && copiedShort.kind === "share" ? styles.copiedFlash : ""}`}
onClick={() => handleCopyShareLink(s)}
title="Copy share link"
>
{copiedShort?.id === s.id && copiedShort.kind === "share" ? "✓" : "🔗"}
</button>
<button
className={`${styles.downloadLink} ${copiedShort?.id === s.id && copiedShort.kind === "embed" ? styles.copiedFlash : ""}`}
onClick={() => handleCopyEmbed(s)}
title="Copy embed code"
>
{copiedShort?.id === s.id && copiedShort.kind === "embed" ? "✓" : "⧉"}
</button>
</>
)}
</>
)}
{s.status === "failed" && s.error_message && (
<span

View file

@ -0,0 +1,120 @@
/* ── Public Short Player ───────────────────────────────────────────────────── */
.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80vh;
padding: 2rem 1rem;
background: var(--color-bg-body, #0b0f1a);
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 720px;
width: 100%;
}
.video {
width: 100%;
max-height: 80vh;
border-radius: 8px;
background: #000;
}
.meta {
text-align: center;
}
.title {
margin: 0 0 0.25rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary, #e2e8f0);
}
.creator {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted, #64748b);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.copyBtn {
padding: 0.375rem 0.875rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
background: var(--color-bg-surface, #151b2e);
color: var(--color-text-secondary, #94a3b8);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.copyBtn:hover {
background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12));
color: var(--color-accent, #00ffd1);
}
.copied {
color: var(--color-badge-approved-text, #22c55e);
}
.branding {
margin-top: 1rem;
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
}
.branding a {
color: var(--color-accent, #00ffd1);
text-decoration: none;
}
.branding a:hover {
text-decoration: underline;
}
/* ── Loading / Error ──────────────────────────────────────────────────────── */
.loadingState,
.errorState {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
color: var(--color-text-muted, #64748b);
font-size: 0.875rem;
}
.errorState {
flex-direction: column;
gap: 0.5rem;
}
.errorMessage {
color: var(--color-error, #ef4444);
}
/* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.page {
padding: 1rem 0.5rem;
}
.title {
font-size: 1rem;
}
}

View file

@ -0,0 +1,150 @@
import { useEffect, useState, useCallback } from "react";
import { useParams } from "react-router-dom";
import { fetchPublicShort, type PublicShortResponse } from "../api/shorts";
import styles from "./ShortPlayer.module.css";
/**
* Copy text to clipboard with execCommand fallback for older browsers.
* Returns true on success.
*/
async function copyToClipboard(text: string): Promise<boolean> {
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 `<iframe src="${src}" width="${width}" height="${height}" frameborder="0" allowfullscreen></iframe>`;
}
export default function ShortPlayer() {
const { token } = useParams<{ token: string }>();
const [data, setData] = useState<PublicShortResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className={styles.loadingState}>Loading</div>
);
}
if (error || !data) {
return (
<div className={styles.errorState}>
<p className={styles.errorMessage}>{error ?? "Short not found"}</p>
<p>This short may have been removed or the link is invalid.</p>
</div>
);
}
return (
<div className={styles.page}>
<div className={styles.wrapper}>
<video
className={styles.video}
src={data.download_url}
controls
autoPlay
playsInline
width={data.width}
height={data.height}
/>
<div className={styles.meta}>
<h1 className={styles.title}>{data.highlight_title}</h1>
<p className={styles.creator}>by {data.creator_name}</p>
</div>
<div className={styles.actions}>
<button
className={`${styles.copyBtn} ${copied === "share" ? styles.copied : ""}`}
onClick={handleCopyShare}
>
{copied === "share" ? "Copied!" : "Copy Share Link"}
</button>
<button
className={`${styles.copyBtn} ${copied === "embed" ? styles.copied : ""}`}
onClick={handleCopyEmbed}
>
{copied === "embed" ? "Copied!" : "Copy Embed Code"}
</button>
</div>
<p className={styles.branding}>
Powered by <a href="/">Chrysopedia</a>
</p>
</div>
</div>
);
}

View file

@ -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"}
{"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"}