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() {
|
||||
const [status, setStatus] = useState(null); // null | { imported, skipped, errors }
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [showPaste, setShowPaste] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const doImport = async (handles) => {
|
||||
if (!handles.length) {
|
||||
setStatus({ error: "No channel handles found." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
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);
|
||||
const res = await followBulk(handles);
|
||||
setStatus(res.data);
|
||||
} catch (err) {
|
||||
setStatus({ error: err.response?.data?.detail || err.message });
|
||||
} finally {
|
||||
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 (
|
||||
<Section title="Subscriptions">
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="px-5 py-4 flex flex-col gap-4">
|
||||
{/* CSV import row */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
Upload the <span className="font-mono">subscriptions.csv</span> from a YouTube Google Takeout export.
|
||||
Channels already followed are skipped.
|
||||
</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">Upload the <span className="font-mono">subscriptions.csv</span> file.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
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"}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFile} />
|
||||
</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 && (
|
||||
<p className="text-sm text-zinc-300">
|
||||
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.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>
|
||||
)}
|
||||
{status?.error && (
|
||||
<p className="text-sm text-red-400">{status.error}</p>
|
||||
)}
|
||||
{status?.error && <p className="text-sm text-red-400">{status.error}</p>}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user