Add paste-from-YouTube import to subscription section
Alongside the CSV option, add a text area that accepts copy-pasted text from the YouTube subscriptions page. Extracts @handle from lines like '@handle•N subscribers' using regex, deduplicates, then calls follow-bulk. Button live-counts how many handles are detected as you paste. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -339,68 +339,113 @@ function DiagnosticSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionImportSection() {
|
function SubscriptionImportSection() {
|
||||||
const [status, setStatus] = useState(null); // null | { imported, skipped, errors }
|
const [status, setStatus] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pasteText, setPasteText] = useState("");
|
||||||
|
const [showPaste, setShowPaste] = useState(false);
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
|
|
||||||
const handleFile = async (e) => {
|
const doImport = async (handles) => {
|
||||||
const file = e.target.files?.[0];
|
if (!handles.length) {
|
||||||
if (!file) return;
|
setStatus({ error: "No channel handles found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const res = await followBulk(handles);
|
||||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
||||||
// Skip header row (Kanal-id or Channel ID or similar)
|
|
||||||
const dataLines = lines.slice(1);
|
|
||||||
// First column is the channel ID (UCxxxxxxx)
|
|
||||||
const ids = dataLines
|
|
||||||
.map((l) => l.split(",")[0]?.replace(/^"|"$/g, "").trim())
|
|
||||||
.filter((id) => id?.startsWith("UC"));
|
|
||||||
if (!ids.length) {
|
|
||||||
setStatus({ error: "No channel IDs found. Expected UC... IDs in the first column." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await followBulk(ids);
|
|
||||||
setStatus(res.data);
|
setStatus(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus({ error: err.response?.data?.detail || err.message });
|
setStatus({ error: err.response?.data?.detail || err.message });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFile = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||||
|
const dataLines = lines.slice(1);
|
||||||
|
const ids = dataLines
|
||||||
|
.map((l) => l.split(",")[0]?.replace(/^"|"$/g, "").trim())
|
||||||
|
.filter((id) => id?.startsWith("UC"));
|
||||||
|
await doImport(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async () => {
|
||||||
|
// Extract @handles from YouTube subscription page text — lines like "@handle•N subscribers"
|
||||||
|
const handles = [...new Set(
|
||||||
|
(pasteText.match(/@[\w.-]+(?=•)/g) || [])
|
||||||
|
)];
|
||||||
|
await doImport(handles);
|
||||||
|
setPasteText("");
|
||||||
|
setShowPaste(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Subscriptions">
|
<Section title="Subscriptions">
|
||||||
<div className="px-5 py-4 flex flex-col gap-3">
|
<div className="px-5 py-4 flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
{/* CSV import row */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout</p>
|
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout CSV</p>
|
||||||
<p className="text-xs text-zinc-500 mt-0.5">
|
<p className="text-xs text-zinc-500 mt-0.5">Upload the <span className="font-mono">subscriptions.csv</span> file.</p>
|
||||||
Upload the <span className="font-mono">subscriptions.csv</span> from a YouTube Google Takeout export.
|
|
||||||
Channels already followed are skipped.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50"
|
className="shrink-0 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "Importing…" : "Import CSV"}
|
{loading ? "Importing…" : "Import CSV"}
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFile} />
|
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFile} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Paste from YouTube row */}
|
||||||
|
<div className="flex flex-col gap-2 border-t border-zinc-800 pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-zinc-200">Paste from YouTube</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">Copy your subscriptions list from YouTube and paste it here — handles starting with @ are extracted automatically.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPaste((v) => !v)}
|
||||||
|
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPaste ? "Cancel" : "Paste list"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showPaste && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={pasteText}
|
||||||
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
|
placeholder="Paste your YouTube subscription list here…"
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-zinc-300 font-mono resize-none focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handlePaste}
|
||||||
|
disabled={loading || !pasteText.trim()}
|
||||||
|
className="self-end px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Importing…" : `Import ${(pasteText.match(/@[\w.-]+(?=•)/g) || []).length} channels`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{status && !status.error && (
|
{status && !status.error && (
|
||||||
<p className="text-sm text-zinc-300">
|
<p className="text-sm text-zinc-300">
|
||||||
Followed <span className="text-accent font-semibold">{status.followed ?? 0}</span> new channels
|
Followed <span className="text-accent font-semibold">{status.followed ?? 0}</span> new channels
|
||||||
{status.already_following > 0 && <span className="text-zinc-500"> · {status.already_following} already following</span>}
|
{status.already_following > 0 && <span className="text-zinc-500"> · {status.already_following} already following</span>}
|
||||||
{status.new_channels > 0 && <span className="text-zinc-500"> · {status.new_channels} stubs created (index to fetch metadata)</span>}
|
{status.new_channels > 0 && <span className="text-zinc-500"> · {status.new_channels} stubs created</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{status?.error && (
|
{status?.error && <p className="text-sm text-red-400">{status.error}</p>}
|
||||||
<p className="text-sm text-red-400">{status.error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user