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:
parent
5f4b960dc1
commit
160b1a8445
10 changed files with 498 additions and 9 deletions
|
|
@ -18,7 +18,7 @@
|
||||||
- Estimate: 1.5h
|
- 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
|
- 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'
|
- 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
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
40
.gsd/milestones/M024/slices/S01/tasks/T01-VERIFY.json
Normal file
40
.gsd/milestones/M024/slices/S01/tasks/T01-VERIFY.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
86
.gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md
Normal file
86
.gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md
Normal 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.
|
||||||
|
|
@ -27,6 +27,7 @@ const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
||||||
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
|
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
|
||||||
const PostEditor = React.lazy(() => import("./pages/PostEditor"));
|
const PostEditor = React.lazy(() => import("./pages/PostEditor"));
|
||||||
const PostsList = React.lazy(() => import("./pages/PostsList"));
|
const PostsList = React.lazy(() => import("./pages/PostsList"));
|
||||||
|
const ShortPlayer = React.lazy(() => import("./pages/ShortPlayer"));
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||||
import AppFooter from "./components/AppFooter";
|
import AppFooter from "./components/AppFooter";
|
||||||
|
|
@ -180,6 +181,7 @@ function AppShell() {
|
||||||
<Route path="/techniques/:slug" element={<TechniquePage />} />
|
<Route path="/techniques/:slug" element={<TechniquePage />} />
|
||||||
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
|
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
|
||||||
<Route path="/chat" element={<Suspense fallback={<LoadingFallback />}><ChatPage /></Suspense>} />
|
<Route path="/chat" element={<Suspense fallback={<LoadingFallback />}><ChatPage /></Suspense>} />
|
||||||
|
<Route path="/shorts/:token" element={<Suspense fallback={<LoadingFallback />}><ShortPlayer /></Suspense>} />
|
||||||
|
|
||||||
{/* Browse routes */}
|
{/* Browse routes */}
|
||||||
<Route path="/creators" element={<CreatorsBrowse />} />
|
<Route path="/creators" element={<CreatorsBrowse />} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { request, BASE } from "./client";
|
import { request, BASE, ApiError } from "./client";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -13,6 +13,17 @@ export interface GeneratedShort {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
created_at: string;
|
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 {
|
export interface ShortsListResponse {
|
||||||
|
|
@ -51,3 +62,20 @@ export function getShortDownloadUrl(
|
||||||
`${BASE}/admin/shorts/download/${encodeURIComponent(shortId)}`,
|
`${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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,10 @@
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copiedFlash {
|
||||||
|
color: var(--color-badge-approved-text, #22c55e) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.shortError {
|
.shortError {
|
||||||
color: var(--color-badge-rejected-text, #ef4444);
|
color: var(--color-badge-rejected-text, #ef4444);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@ import styles from "./HighlightQueue.module.css";
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────────────────────────────────────── */
|
/* ── 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 {
|
function formatDuration(secs: number): string {
|
||||||
const m = Math.floor(secs / 60);
|
const m = Math.floor(secs / 60);
|
||||||
const s = Math.floor(secs % 60);
|
const s = Math.floor(secs % 60);
|
||||||
|
|
@ -108,6 +126,7 @@ export default function HighlightQueue() {
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const [generatingIds, setGeneratingIds] = useState<Set<string>>(new Set());
|
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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const loadHighlights = useCallback(async (tab: FilterTab) => {
|
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 }[] = [
|
const tabs: { key: FilterTab; label: string }[] = [
|
||||||
{ key: "all", label: "All" },
|
{ key: "all", label: "All" },
|
||||||
{ key: "shorts", label: "Shorts" },
|
{ key: "shorts", label: "Shorts" },
|
||||||
|
|
@ -443,12 +481,33 @@ export default function HighlightQueue() {
|
||||||
{s.status}
|
{s.status}
|
||||||
</span>
|
</span>
|
||||||
{s.status === "complete" && (
|
{s.status === "complete" && (
|
||||||
<button
|
<>
|
||||||
className={styles.downloadLink}
|
<button
|
||||||
onClick={() => handleDownload(s.id)}
|
className={styles.downloadLink}
|
||||||
>
|
onClick={() => handleDownload(s.id)}
|
||||||
↓
|
title="Download"
|
||||||
</button>
|
>
|
||||||
|
↓
|
||||||
|
</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 && (
|
{s.status === "failed" && s.error_message && (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
120
frontend/src/pages/ShortPlayer.module.css
Normal file
120
frontend/src/pages/ShortPlayer.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
frontend/src/pages/ShortPlayer.tsx
Normal file
150
frontend/src/pages/ShortPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"}
|
||||||
Loading…
Add table
Reference in a new issue