Add PWA support and Google Takeout subscription import

- manifest.json + icon.svg for installability on mobile (standalone mode)
- index.html: theme-color, apple-mobile-web-app meta tags, manifest link
- Settings: Import CSV section reads Google Takeout subscriptions.csv,
  extracts UC... channel IDs, calls follow-bulk to follow them all at once

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattias Tall
2026-05-26 11:04:42 +02:00
parent 219a388d72
commit a4cd32da4a
4 changed files with 110 additions and 3 deletions

View File

@@ -2,8 +2,15 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube Hub</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#09090b" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="YT Hub" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>YT Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

4
frontend/public/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#09090b"/>
<text x="256" y="340" font-family="Arial, sans-serif" font-weight="900" font-size="240" text-anchor="middle" fill="#facc15">YT</text>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -0,0 +1,27 @@
{
"name": "YT Hub",
"short_name": "YT Hub",
"description": "Your personal YouTube archive",
"start_url": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig, initOAuth2, getOAuth2Status, enableOAuth2, disableOAuth2, testYtdlp } from "../api";
import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig, initOAuth2, getOAuth2Status, enableOAuth2, disableOAuth2, testYtdlp, followBulk } from "../api";
import { useAuth } from "../hooks/useAuth";
const REGION_OPTIONS = [
@@ -338,6 +338,74 @@ function DiagnosticSection() {
);
}
function SubscriptionImportSection() {
const [status, setStatus] = useState(null); // null | { imported, skipped, errors }
const [loading, setLoading] = useState(false);
const fileRef = useRef(null);
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (!file) 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);
setStatus(res.data);
} catch (err) {
setStatus({ error: err.response?.data?.detail || err.message });
} finally {
setLoading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<Section title="Subscriptions">
<div className="px-5 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<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>
</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"
>
{loading ? "Importing…" : "Import CSV"}
</button>
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFile} />
</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>}
</p>
)}
{status?.error && (
<p className="text-sm text-red-400">{status.error}</p>
)}
</div>
</Section>
);
}
function OAuth2Section({ s, qc }) {
const [flowState, setFlowState] = useState(null); // null | {status, device_url, code}
const [polling, setPolling] = useState(false);
@@ -535,6 +603,7 @@ export default function SettingsPage() {
<CookiesSection s={s} qc={qc} set={set} />
<OAuth2Section s={s} qc={qc} />
<DiagnosticSection />
<SubscriptionImportSection />
{/* Download quality */}
<Section title="Download quality">