diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx index 4a6bcc6..c63dc55 100644 --- a/src/ui/client/AnalyticsPage.tsx +++ b/src/ui/client/AnalyticsPage.tsx @@ -60,7 +60,7 @@ export default function AnalyticsPage() { const r = await api("/youtube/config", { method: "PUT", body: JSON.stringify({ client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }) }); if (r.error) throw new Error(r.error); setClientSecret(""); setHasSecret(true); - setMsg("Saved β€” now run podcli youtube auth in your terminal to authorize"); + setMsg("Saved. Run podcli youtube auth in your terminal to authorize"); } catch (e: any) { setMsg(`Save failed: ${e.message}`); } finally { setBusy(false); } }; @@ -95,7 +95,7 @@ export default function AnalyticsPage() { try { const r = await api("/youtube/learn", { method: "POST", body: "{}" }); if (r.error) throw new Error(r.error); - setMsg("Analysis written to the knowledge base β€” Claude will use it when picking shorts"); + setMsg("Analysis written to the knowledge base. Claude will use it when picking shorts"); } catch (e: any) { setMsg(`Analysis failed: ${e.message}`); } finally { setBusy(false); } }; @@ -160,7 +160,7 @@ export default function AnalyticsPage() {
Link clips to uploads
{proposals.length === 0 ? ( -
No proposals β€” every clip is linked, or no upload matched.
+
No proposals. Every clip is linked, or no upload matched.
) : ( proposals.map((p) => (
@@ -181,19 +181,18 @@ export default function AnalyticsPage() { {!data || data.published === 0 ? (
-
πŸ“Š
No performance data yet.
- Connect YouTube and Sync, or Import a YouTube Studio analytics CSV. + Connect YouTube and sync, or import a YouTube Studio analytics CSV.
) : ( <>
- - + + - +
diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index b5d97c8..470f442 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -3,6 +3,7 @@ import { Link, useParams, useNavigate } from "react-router-dom"; import { api, upload, fmt, basename, labelStyle } from "./lib"; import ClipPlayer from "./ClipPlayer"; import ReframeEditor from "./ReframeEditor"; +import CopyButton from "./CopyButton"; interface ThumbnailConfig { text?: string; @@ -107,7 +108,7 @@ export default function ClipDetail() { setTextOpts(r.texts || []); setFrameOpts(r.frames || []); if ((r.frames || []).length) setSelFrame({ path: r.frames[0].path, info: r.frames[0] }); - if (!(r.texts || []).length && !(r.frames || []).length) setMsg("No options β€” is the AI CLI installed and the source video available?"); + if (!(r.texts || []).length && !(r.frames || []).length) setMsg("No options. Is the AI CLI installed and the source video available?"); } catch (e: any) { setMsg(`Options failed: ${e.message}`); } finally { setBusy(null); } }; @@ -168,15 +169,11 @@ export default function ClipDetail() { }; const r = await api("/generate-content", { method: "POST", body: JSON.stringify(body) }); if (r.error) throw new Error(r.error); - if (!r.titles?.length && !r.description) throw new Error("AI CLI returned nothing β€” is claude/codex installed?"); + if (!r.titles?.length && !r.description) throw new Error("AI CLI returned nothing. Is claude/codex installed?"); load(); } catch (e: any) { setMsg(`Content generation failed: ${e.message}`); } finally { setBusy(null); } }; - const copy = (text: string) => { - navigator.clipboard?.writeText(text).then(() => setMsg("Copied to clipboard"), () => {}); - }; - const del = async () => { if (!window.confirm(`Delete "${clip.title}"? This removes the rendered file too.`)) return; setBusy("delete"); setMsg(null); @@ -206,7 +203,7 @@ export default function ClipDetail() { {clip.output_path ? (playerTime.current = t)} /> :
No rendered output
}
- {fmt(clip.start_second)}–{fmt(clip.end_second)} Β· {clip.duration}s + {fmt(clip.start_second)}-{fmt(clip.end_second)} Β· {clip.duration}s {clip.crop_strategy} {clip.content_type && {clip.content_type}} {clip.file_size_mb != null && {clip.file_size_mb.toFixed(1)}MB} @@ -268,7 +265,7 @@ export default function ClipDetail() { e.target.files?.[0] && uploadFrame(e.target.files[0])} />
-
Leave Line 1 & 2 empty to auto-write the text.
+
Leave line 1 and line 2 empty to auto-write the text.
@@ -317,7 +314,7 @@ export default function ClipDetail() { {clip.generated_titles.map((t, i) => { const clean = t.replace(/^\d+\.\s*/, ""); return ( - ); @@ -329,7 +326,7 @@ export default function ClipDetail() {
Description - +
{clip.description}
@@ -338,7 +335,7 @@ export default function ClipDetail() {
Tags - +
{clip.tags}
@@ -347,7 +344,7 @@ export default function ClipDetail() {
Hashtags - +
{clip.hashtags}
diff --git a/src/ui/client/CopyButton.tsx b/src/ui/client/CopyButton.tsx new file mode 100644 index 0000000..825e505 --- /dev/null +++ b/src/ui/client/CopyButton.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef, useState } from "react"; + +type CopyButtonProps = { + text?: string; + getText?: () => string; + label?: string; + copiedLabel?: string; + className?: string; + title?: string; + disabled?: boolean; + stopPropagation?: boolean; + iconOnly?: boolean; + resetMs?: number; + style?: React.CSSProperties; + onCopied?: () => void; +}; + +function CopyIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +export default function CopyButton({ + text, + getText, + label = "Copy", + copiedLabel = "Copied", + className = "copy-btn", + title, + disabled = false, + stopPropagation = false, + iconOnly = false, + resetMs = 1600, + style, + onCopied, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + return () => { + if (timerRef.current) window.clearTimeout(timerRef.current); + }; + }, []); + + const handleCopy = async (event: React.MouseEvent) => { + if (stopPropagation) event.stopPropagation(); + const value = getText ? getText() : text; + if (!value) return; + + try { + await navigator.clipboard.writeText(value); + setCopied(true); + onCopied?.(); + + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setCopied(false), resetMs); + } catch { + setCopied(false); + } + }; + + return ( + + ); +} diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index ac856f8..e905bb0 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import CopyButton from './CopyButton'; const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; const api = async (path, opts = {}) => { @@ -461,10 +462,14 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( }).catch(() => { }); }, [phase, videoPath, !!transcript, !!transcriptText, suggestions?.length, mcpConnected]); - const copyPrompt = (prompt, idx) => { - navigator.clipboard.writeText(prompt).then(() => { + const markCopied = (idx) => { setCopied(idx); setTimeout(() => setCopied(null), 1500); + }; + + const copyPrompt = (prompt, idx) => { + navigator.clipboard.writeText(prompt).then(() => { + markCopied(idx); }).catch(() => { }); }; @@ -475,9 +480,9 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
setCollapsed(!collapsed)}>
AI
-
MCP Prompts
+
MCP prompts
- {collapsed ? `${hints.length} prompts` : 'click to copy'} + {collapsed ? `${hints.length} prompts` : 'Click to copy'}
{'\u25BC'}
@@ -492,9 +497,14 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( {copied === i ? 'Copied!' : hint.prompt} {hint.description} - + markCopied(i)} + />
))}
@@ -646,6 +656,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( }; const deletePreset = async (name) => { + if (!confirm(`Delete preset "${name}"?`)) return; try { await api('/presets', { method: 'POST', body: JSON.stringify({ action: 'delete', name }) }); if (activePreset === name) setActivePreset(''); @@ -673,6 +684,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const deleteClipEdit = () => { if (editingClip === null) return; + if (!confirm(`Delete clip "${editForm.title}" from this batch?`)) return; setSuggestions(prev => prev.filter((_, i) => i !== editingClip)); setDeselected(prev => { const next = new Set(); @@ -913,7 +925,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const d = await api('/find-moment', { method: 'POST', body: JSON.stringify({ text }) }); if (d.error) { setError(d.error); return; } if (!d.added) { - setMomentNotice(d.found ? 'Those moments are already in your clips.' : "Couldn't find that moment β€” try different wording or a direct quote."); + setMomentNotice(d.found ? 'Those moments are already in your clips.' : "Couldn't find that moment. Try different wording or a direct quote."); return; } // suggestions refresh via the SSE state-sync broadcast @@ -988,7 +1000,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( parts.push('Then find the 5-8 best viral-worthy moments and call suggest_clips.'); } else { parts.push('Use get_ui_state with include_transcript=true to read the full transcript.'); - parts.push('Find the 5-8 best viral-worthy moments β€” hot takes, strong opinions, funny moments, actionable advice, and emotional stories.'); + parts.push('Find the 5-8 best viral-worthy moments: hot takes, strong opinions, funny moments, actionable advice, and emotional stories.'); parts.push('Then call suggest_clips with your suggestions.'); } const settings = []; @@ -1138,7 +1150,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( )} {speakerStatus && !speakerStatus.configured && ( window.open('https://huggingface.co/pyannote/speaker-diarization-3.1', '_blank')}> Speakers βœ— @@ -1155,7 +1167,6 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( {speakerStatus && !speakerStatus.configured && !sessionStorage.getItem('dismiss-speaker') && (
- πŸŽ™οΈ
Set up speaker detection
@@ -1191,7 +1202,6 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
) : ( <> -
{'\uD83C\uDFAC'}
Browse to select a video file
)} @@ -1214,7 +1224,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
Transcript
-
setTranscriptMode('import')}>Paste Transcript
+
setTranscriptMode('import')}>Paste transcript
setTranscriptMode('whisper')}>Auto (Whisper)
{transcriptMode === 'import' && ( @@ -1223,7 +1233,6 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
{ preventDef(e); setTranscriptDragOver(true); }} onDragLeave={() => setTranscriptDragOver(false)} onDrop={handleTranscriptDrop} style={{ marginBottom: 10, padding: '22px 20px' }}> -
{'\uD83D\uDCC4'}
Drop a transcript file or browse
@@ -1235,7 +1244,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
)} -