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