Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions src/ui/client/AnalyticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
};

Expand Down Expand Up @@ -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); }
};

Expand Down Expand Up @@ -160,7 +160,7 @@ export default function AnalyticsPage() {
<div className="section">
<div className="section-label">Link clips to uploads</div>
{proposals.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--text2)" }}>No proposals — every clip is linked, or no upload matched.</div>
<div style={{ fontSize: 13, color: "var(--text2)" }}>No proposals. Every clip is linked, or no upload matched.</div>
) : (
proposals.map((p) => (
<div key={p.clip_id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 0", borderBottom: "1px solid var(--border)" }}>
Expand All @@ -181,19 +181,18 @@ export default function AnalyticsPage() {

{!data || data.published === 0 ? (
<div className="drop-zone" style={{ textAlign: "center", padding: "48px 20px", color: "var(--text2)" }}>
<div className="icon" style={{ fontSize: 26 }}>📊</div>
<div className="label" style={{ marginTop: 8 }}>No performance data yet.</div>
<div style={{ fontSize: 12, color: "var(--text3)", marginTop: 6 }}>
Connect YouTube and Sync, or Import a YouTube Studio analytics CSV.
Connect YouTube and sync, or import a YouTube Studio analytics CSV.
</div>
</div>
) : (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 16, marginBottom: 8 }}>
<Group title="Retention by content type what holds viewers" rows={data.byContentType} metric="avgRetention" />
<Group title="CTR by caption style packaging" rows={data.byCaptionStyle} metric="avgCtr" />
<Group title="Retention by content type: what holds viewers" rows={data.byContentType} metric="avgRetention" />
<Group title="CTR by caption style: packaging" rows={data.byCaptionStyle} metric="avgCtr" />
<Group title="Retention by length" rows={data.byLength} metric="avgRetention" />
<Group title="Views by content type reach" rows={data.byContentType} metric="avgViews" />
<Group title="Views by content type: reach" rows={data.byContentType} metric="avgViews" />
</div>

<div className="section">
Expand Down
21 changes: 9 additions & 12 deletions src/ui/client/ClipDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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); }
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -206,7 +203,7 @@ export default function ClipDetail() {
{clip.output_path ? <ClipPlayer key={previewUrl} src={previewUrl} onTime={(t) => (playerTime.current = t)} /> : <div className="phone-empty">No rendered output</div>}
<button className="btn btn-ghost btn-sm" style={{ width: "100%", marginTop: 10 }} onClick={() => setReframing(true)}>Reframe (fix camera)</button>
<div className="clip-meta">
<span>{fmt(clip.start_second)}{fmt(clip.end_second)} · {clip.duration}s</span>
<span>{fmt(clip.start_second)}-{fmt(clip.end_second)} · {clip.duration}s</span>
<span>{clip.crop_strategy}</span>
{clip.content_type && <span>{clip.content_type}</span>}
{clip.file_size_mb != null && <span>{clip.file_size_mb.toFixed(1)}MB</span>}
Expand Down Expand Up @@ -268,7 +265,7 @@ export default function ClipDetail() {
</button>
<input ref={fileRef} type="file" accept=".png,.jpg,.jpeg,.webp" style={{ display: "none" }} onChange={(e) => e.target.files?.[0] && uploadFrame(e.target.files[0])} />
</div>
<div style={{ fontSize: 11, color: "var(--text3)", marginTop: 8 }}>Leave Line 1 &amp; 2 empty to auto-write the text.</div>
<div style={{ fontSize: 11, color: "var(--text3)", marginTop: 8 }}>Leave line 1 and line 2 empty to auto-write the text.</div>
</div>
</div>

Expand Down Expand Up @@ -317,7 +314,7 @@ export default function ClipDetail() {
{clip.generated_titles.map((t, i) => {
const clean = t.replace(/^\d+\.\s*/, "");
return (
<button key={i} className={`title-option ${title === clean ? "selected" : ""}`} onClick={() => { setTitle(clean); setMsg("Title set — click Save to apply"); }}>
<button key={i} className={`title-option ${title === clean ? "selected" : ""}`} onClick={() => { setTitle(clean); setMsg("Title set. Click save to apply"); }}>
{t}
</button>
);
Expand All @@ -329,7 +326,7 @@ export default function ClipDetail() {
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 11, color: "var(--text3)" }}>Description</span>
<button className="copy-btn" onClick={() => copy(clip.description!)}>Copy</button>
<CopyButton text={clip.description} />
</div>
<div style={{ fontSize: 13, color: "var(--text2)", lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{clip.description}</div>
</div>
Expand All @@ -338,7 +335,7 @@ export default function ClipDetail() {
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 11, color: "var(--text3)" }}>Tags</span>
<button className="copy-btn" onClick={() => copy(clip.tags!)}>Copy</button>
<CopyButton text={clip.tags} />
</div>
<div style={{ fontSize: 12, color: "var(--text2)", lineHeight: 1.6 }}>{clip.tags}</div>
</div>
Expand All @@ -347,7 +344,7 @@ export default function ClipDetail() {
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 11, color: "var(--text3)" }}>Hashtags</span>
<button className="copy-btn" onClick={() => copy(clip.hashtags!)}>Copy</button>
<CopyButton text={clip.hashtags} />
</div>
<div style={{ fontSize: 12, color: "var(--accent)", lineHeight: 1.6 }}>{clip.hashtags}</div>
</div>
Expand Down
96 changes: 96 additions & 0 deletions src/ui/client/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg className="copy-button-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 8h10v12H8z" />
<path d="M6 16H4V4h12v2" />
</svg>
);
}

function CheckIcon() {
return (
<svg className="copy-button-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
</svg>
);
}

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<number | null>(null);

useEffect(() => {
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, []);

const handleCopy = async (event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<button
type="button"
className={`${className} copy-button ${copied ? "is-copied" : ""} ${iconOnly ? "is-icon-only" : ""}`}
onClick={handleCopy}
disabled={disabled}
title={title ?? label}
aria-label={copied ? copiedLabel : label}
aria-live="polite"
style={style}
>
<span className="copy-button-layer copy-button-idle">
<CopyIcon />
{!iconOnly && <span>{label}</span>}
</span>
<span className="copy-button-layer copy-button-success">
<CheckIcon />
{!iconOnly && <span>{copiedLabel}</span>}
</span>
</button>
);
}
Loading
Loading