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