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:
@@ -2,8 +2,15 @@
|
|||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>YouTube Hub</title>
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
4
frontend/public/icon.svg
Normal file
4
frontend/public/icon.svg
Normal 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 |
27
frontend/public/manifest.json
Normal file
27
frontend/public/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
const REGION_OPTIONS = [
|
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 }) {
|
function OAuth2Section({ s, qc }) {
|
||||||
const [flowState, setFlowState] = useState(null); // null | {status, device_url, code}
|
const [flowState, setFlowState] = useState(null); // null | {status, device_url, code}
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
@@ -535,6 +603,7 @@ export default function SettingsPage() {
|
|||||||
<CookiesSection s={s} qc={qc} set={set} />
|
<CookiesSection s={s} qc={qc} set={set} />
|
||||||
<OAuth2Section s={s} qc={qc} />
|
<OAuth2Section s={s} qc={qc} />
|
||||||
<DiagnosticSection />
|
<DiagnosticSection />
|
||||||
|
<SubscriptionImportSection />
|
||||||
|
|
||||||
{/* Download quality */}
|
{/* Download quality */}
|
||||||
<Section title="Download quality">
|
<Section title="Download quality">
|
||||||
|
|||||||
Reference in New Issue
Block a user