feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share, usage, index, admin storage/quota) - format.ts: formatBytes / formatSpeed - i18n: en/hu/de downloads.json (trilingual) - Downloads nav module (Download icon, active-count badge, hidden for demo) placed after Messages; urlState page + App render + Header title - DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the per-user download index: downloaded / in-queue), opens the preset dialog - DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast - ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock) - DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share, admin storage dashboard + per-user quota editor - wired DownloadButton into VideoCard + PlayerModal - lib/nav.ts: decoupled navigator so the download toast can jump to the page tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library shows a real download with usage bar + actions, no console errors.
This commit is contained in:
parent
51158ad9cf
commit
7d1ed24fea
17 changed files with 1551 additions and 1 deletions
|
|
@ -279,6 +279,29 @@ def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db))
|
||||||
return quota.usage(db, user.id)
|
return quota.usage(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||||
|
_INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/index")
|
||||||
|
def my_download_index(
|
||||||
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""source_ref -> best active status, for the feed's per-card "downloaded / queued" badge.
|
||||||
|
Small + cheap so the feed can poll it without loading the full job list."""
|
||||||
|
rows = db.execute(
|
||||||
|
select(DownloadJob.source_ref, DownloadJob.status).where(
|
||||||
|
DownloadJob.user_id == user.id,
|
||||||
|
DownloadJob.status.in_(("queued", "running", "paused", "done")),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for ref, status in rows:
|
||||||
|
if _INDEX_RANK.get(status, 0) > _INDEX_RANK.get(out.get(ref, ""), 0):
|
||||||
|
out[ref] = status
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shared")
|
@router.get("/shared")
|
||||||
def shared_with_me(
|
def shared_with_me(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import NotificationsPanel from "./components/NotificationsPanel";
|
import NotificationsPanel from "./components/NotificationsPanel";
|
||||||
import Messages from "./components/Messages";
|
import Messages from "./components/Messages";
|
||||||
|
import DownloadCenter from "./components/DownloadCenter";
|
||||||
|
import { setNavigator } from "./lib/nav";
|
||||||
import ChatDock from "./components/ChatDock";
|
import ChatDock from "./components/ChatDock";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||||
|
|
@ -279,6 +281,8 @@ export default function App() {
|
||||||
}
|
}
|
||||||
go();
|
go();
|
||||||
}
|
}
|
||||||
|
// Expose the current setPage to decoupled callers (download toast "View", etc.).
|
||||||
|
useEffect(() => setNavigator(setPage));
|
||||||
|
|
||||||
function setSidebarLayout(next: SidebarLayout) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
setSidebarLayoutState(next);
|
setSidebarLayoutState(next);
|
||||||
|
|
@ -728,6 +732,8 @@ export default function App() {
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
<Messages meId={meQuery.data!.id} />
|
<Messages meId={meQuery.data!.id} />
|
||||||
</div>
|
</div>
|
||||||
|
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
||||||
|
<DownloadCenter me={meQuery.data!} />
|
||||||
) : page === "settings" ? (
|
) : page === "settings" ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
|
|
|
||||||
66
frontend/src/components/DownloadButton.tsx
Normal file
66
frontend/src/components/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import DownloadDialog from "./DownloadDialog";
|
||||||
|
import { api, type Me } from "../lib/api";
|
||||||
|
|
||||||
|
// Self-contained download affordance for a video card / the player. Hidden for the demo account
|
||||||
|
// (downloads spend server disk + need a real identity). Reflects the video's current state from
|
||||||
|
// the shared per-user download index (one polled query shared across every card).
|
||||||
|
export default function DownloadButton({
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
videoId: string;
|
||||||
|
title?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const me = qc.getQueryData<Me>(["me"]);
|
||||||
|
const isDemo = !!me?.is_demo;
|
||||||
|
|
||||||
|
const indexQ = useQuery({
|
||||||
|
queryKey: ["download-index"],
|
||||||
|
queryFn: api.downloadIndex,
|
||||||
|
enabled: !isDemo,
|
||||||
|
staleTime: 10000,
|
||||||
|
});
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isDemo) return null;
|
||||||
|
|
||||||
|
const status = indexQ.data?.[videoId];
|
||||||
|
const downloaded = status === "done";
|
||||||
|
const inQueue = status === "queued" || status === "running" || status === "paused";
|
||||||
|
const label = downloaded
|
||||||
|
? t("downloads.button.downloaded")
|
||||||
|
: inQueue
|
||||||
|
? t("downloads.button.queued")
|
||||||
|
: t("downloads.button.label");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
className={clsx(
|
||||||
|
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
|
(downloaded || inQueue) && "text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
511
frontend/src/components/DownloadCenter.tsx
Normal file
511
frontend/src/components/DownloadCenter.tsx
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
Download,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Settings2,
|
||||||
|
Share2,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import DownloadDialog from "./DownloadDialog";
|
||||||
|
import ProfileEditor from "./ProfileEditor";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
import { api, type DownloadJob, type Me } from "../lib/api";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||||
|
const GiB = 1024 ** 3;
|
||||||
|
|
||||||
|
const STATUS_CLS: Record<string, string> = {
|
||||||
|
queued: "text-muted",
|
||||||
|
running: "text-accent",
|
||||||
|
paused: "text-amber-400",
|
||||||
|
done: "text-emerald-400",
|
||||||
|
error: "text-red-400",
|
||||||
|
canceled: "text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusPill({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const key = job.expired ? "expired" : job.status;
|
||||||
|
return (
|
||||||
|
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||||
|
{t(`downloads.status.${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ job }: { job: DownloadJob }) {
|
||||||
|
const url = job.asset?.thumbnail_url;
|
||||||
|
return (
|
||||||
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
|
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobTitle(job: DownloadJob): string {
|
||||||
|
return job.display_name || job.asset?.title || job.source_ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBtn({
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
danger,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
danger?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||||
|
danger ? "hover:text-red-400" : "hover:text-fg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadRow({
|
||||||
|
job,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
job: DownloadJob;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const running = job.status === "running";
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 p-2.5 rounded-xl bg-card/40 hover:bg-card transition">
|
||||||
|
<Thumb job={job} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium leading-snug line-clamp-1">{jobTitle(job)}</div>
|
||||||
|
<div className="text-xs text-muted truncate mt-0.5">
|
||||||
|
{job.asset?.uploader || job.source_ref}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||||
|
<StatusPill job={job} />
|
||||||
|
{job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null}
|
||||||
|
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
|
||||||
|
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
{running && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className="h-full bg-accent transition-all" style={{ width: `${job.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted mt-0.5 flex gap-2">
|
||||||
|
<span>{job.progress}%</span>
|
||||||
|
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||||
|
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rename + Share small modals ------------------------------------------------------------
|
||||||
|
|
||||||
|
function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [name, setName] = useState(jobTitle(job));
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.renameDownload(job.id, name.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.rename.title")} onClose={onClose}>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.rename.label")}</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
|
||||||
|
<p className="text-xs text-muted mt-2 mb-4">{t("downloads.rename.hint")}</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={!name.trim() || save.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{t("downloads.rename.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const share = useMutation({
|
||||||
|
mutationFn: () => api.shareDownload(job.id, email.trim()),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.share.title")} onClose={onClose}>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.share.label")}</label>
|
||||||
|
<input
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={t("downloads.share.placeholder")}
|
||||||
|
className={inputCls}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => share.mutate()}
|
||||||
|
disabled={!email.trim() || share.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{t("downloads.share.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Usage bar ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function UsageBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const usage = useQuery({ queryKey: ["download-usage"], queryFn: api.downloadUsage });
|
||||||
|
const u = usage.data;
|
||||||
|
if (!u) return null;
|
||||||
|
const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-card/40 p-3 mb-4">
|
||||||
|
<div className="flex justify-between text-sm mb-1.5">
|
||||||
|
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
{formatBytes(u.footprint_bytes)}
|
||||||
|
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||||
|
{u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!u.unlimited && (
|
||||||
|
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className={clsx("h-full transition-all", pct > 90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin system tab -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function QuotaModal({ userId, email, onClose }: { userId: number; email: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({ queryKey: ["dl-quota", userId], queryFn: () => api.adminGetDownloadQuota(userId) });
|
||||||
|
const [form, setForm] = useState<{ gb: number; jobs: number; conc: number; unlimited: boolean } | null>(null);
|
||||||
|
const cur = q.data;
|
||||||
|
const state = form ?? (cur ? { gb: Math.round((cur.max_bytes / GiB) * 10) / 10, jobs: cur.max_jobs, conc: cur.max_concurrent, unlimited: cur.unlimited } : null);
|
||||||
|
const done = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin-storage"] });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.adminSetDownloadQuota(userId, {
|
||||||
|
max_bytes: Math.round((state!.gb) * GiB),
|
||||||
|
max_jobs: state!.jobs,
|
||||||
|
max_concurrent: state!.conc,
|
||||||
|
unlimited: state!.unlimited,
|
||||||
|
}),
|
||||||
|
onSuccess: done,
|
||||||
|
});
|
||||||
|
const reset = useMutation({ mutationFn: () => api.adminResetDownloadQuota(userId), onSuccess: done });
|
||||||
|
if (!state) return null;
|
||||||
|
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxBytes")}
|
||||||
|
<input type="number" value={state.gb} onChange={(e) => upd({ gb: Number(e.target.value) })} className={inputCls} disabled={state.unlimited} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxJobs")}
|
||||||
|
<input type="number" value={state.jobs} onChange={(e) => upd({ jobs: Number(e.target.value) })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxConcurrent")}
|
||||||
|
<input type="number" value={state.conc} onChange={(e) => upd({ conc: Number(e.target.value) })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={state.unlimited} onChange={(e) => upd({ unlimited: e.target.checked })} />
|
||||||
|
{t("downloads.admin.unlimited")}
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-between pt-1">
|
||||||
|
<button onClick={() => reset.mutate()} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
|
||||||
|
{t("downloads.admin.reset")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => save.mutate()} disabled={save.isPending} className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition">
|
||||||
|
{t("downloads.admin.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminSystem() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
|
||||||
|
const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 });
|
||||||
|
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||||
|
const s = storage.data;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{s && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||||
|
{[
|
||||||
|
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||||
|
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||||
|
[t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<div key={label} className="rounded-xl bg-card/40 p-3">
|
||||||
|
<div className="text-xs text-muted">{label}</div>
|
||||||
|
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && s.per_user.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-sm font-medium mb-2">{t("downloads.admin.perUser")}</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{s.per_user.map((u) => (
|
||||||
|
<div key={u.user_id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40">
|
||||||
|
<span className="flex-1 truncate">{u.email}</span>
|
||||||
|
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||||
|
className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
|
||||||
|
title={t("downloads.admin.editQuota")}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(jobs.data ?? []).map((j) => (
|
||||||
|
<div key={j.id} className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40">
|
||||||
|
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
||||||
|
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||||
|
<StatusPill job={j} />
|
||||||
|
<span className="text-muted w-16 text-right">{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{jobs.data && jobs.data.length === 0 && <div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quotaFor && <QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DownloadCenter({ me }: { me: Me }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue");
|
||||||
|
const [addUrl, setAddUrl] = useState("");
|
||||||
|
const [addSource, setAddSource] = useState<string | null>(null);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null);
|
||||||
|
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||||
|
|
||||||
|
const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
|
||||||
|
const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
|
||||||
|
const jobs = jobsQ.data ?? [];
|
||||||
|
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||||
|
const library = jobs.filter((j) => j.status === "done");
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-index"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||||
|
};
|
||||||
|
const act = useMutation({
|
||||||
|
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
|
||||||
|
op === "pause" ? api.pauseDownload(id)
|
||||||
|
: op === "resume" ? api.resumeDownload(id)
|
||||||
|
: op === "cancel" ? api.cancelDownload(id)
|
||||||
|
: api.deleteDownload(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmThen = async (title: string, body: string, fn: () => void) => {
|
||||||
|
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
|
||||||
|
{ id: "done", label: t("downloads.tabs.done"), badge: library.length },
|
||||||
|
{ id: "shared", label: t("downloads.tabs.shared") },
|
||||||
|
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const saveBtn = (job: DownloadJob) => (
|
||||||
|
<a
|
||||||
|
href={api.downloadFileUrl(job.id)}
|
||||||
|
title={t("downloads.actions.download")}
|
||||||
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-4xl w-full mx-auto">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-1">
|
||||||
|
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setManage(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
|
||||||
|
|
||||||
|
<Tabs tabs={tabs} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{tab === "queue" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
value={addUrl}
|
||||||
|
onChange={(e) => setAddUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
|
||||||
|
placeholder={t("downloads.page.addUrl")}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queue.map((job) => (
|
||||||
|
<DownloadRow key={job.id} job={job}>
|
||||||
|
{(job.status === "queued" || job.status === "running") && (
|
||||||
|
<IconBtn onClick={() => act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{(job.status === "paused" || job.status === "error") && (
|
||||||
|
<IconBtn onClick={() => act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))}
|
||||||
|
title={t("downloads.actions.cancel")}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
</DownloadRow>
|
||||||
|
))}
|
||||||
|
{queue.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.queue")}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "done" && (
|
||||||
|
<>
|
||||||
|
<UsageBar />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{library.map((job) => (
|
||||||
|
<DownloadRow key={job.id} job={job}>
|
||||||
|
{saveBtn(job)}
|
||||||
|
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))}
|
||||||
|
title={t("downloads.actions.delete")}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
</DownloadRow>
|
||||||
|
))}
|
||||||
|
{library.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.done")}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "shared" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(sharedQ.data ?? []).map((job) => (
|
||||||
|
<DownloadRow key={job.id} job={job}>
|
||||||
|
{job.can_download && saveBtn(job)}
|
||||||
|
</DownloadRow>
|
||||||
|
))}
|
||||||
|
{sharedQ.data && sharedQ.data.length === 0 && (
|
||||||
|
<div className="text-sm text-muted py-10 text-center">{t("downloads.empty.shared")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||||
|
|
||||||
|
{addSource && (
|
||||||
|
<DownloadDialog
|
||||||
|
source={addSource}
|
||||||
|
onClose={() => {
|
||||||
|
setAddSource(null);
|
||||||
|
setAddUrl("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
|
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||||
|
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/src/components/DownloadDialog.tsx
Normal file
117
frontend/src/components/DownloadDialog.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import ProfileEditor from "./ProfileEditor";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { navigateTo } from "../lib/nav";
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||||
|
|
||||||
|
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
|
||||||
|
// the Download Center. Enqueues via the API and points the user at the Download Center.
|
||||||
|
export default function DownloadDialog({
|
||||||
|
source,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
title?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
|
||||||
|
const [profileId, setProfileId] = useState<number | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
|
||||||
|
const profiles = profilesQ.data ?? [];
|
||||||
|
const chosen = profileId ?? profiles[0]?.id ?? null;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!chosen || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.enqueueDownload({
|
||||||
|
source,
|
||||||
|
profile_id: chosen,
|
||||||
|
display_name: name.trim() || undefined,
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-index"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||||
|
notify({
|
||||||
|
level: "success",
|
||||||
|
message: t("downloads.dialog.added"),
|
||||||
|
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
|
||||||
|
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.profile")}</label>
|
||||||
|
<select
|
||||||
|
value={chosen ?? ""}
|
||||||
|
onChange={(e) => setProfileId(Number(e.target.value))}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManage(true)}
|
||||||
|
className="text-xs text-accent hover:underline mt-1 mb-4"
|
||||||
|
>
|
||||||
|
{t("downloads.dialog.manage")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("downloads.dialog.namePlaceholder")}
|
||||||
|
className={`${inputCls} mb-5`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
{t("downloads.actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !chosen}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{t("downloads.dialog.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manage && (
|
||||||
|
<ProfileEditor
|
||||||
|
onClose={() => {
|
||||||
|
setManage(false);
|
||||||
|
profilesQ.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,8 @@ export default function Header({
|
||||||
? t("inbox.navLabel")
|
? t("inbox.navLabel")
|
||||||
: page === "messages"
|
: page === "messages"
|
||||||
? t("messages.navLabel")
|
? t("messages.navLabel")
|
||||||
|
: page === "downloads"
|
||||||
|
? t("downloads.navLabel")
|
||||||
: t("header.channelManager")}
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Download,
|
||||||
Home,
|
Home,
|
||||||
Info,
|
Info,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
|
|
@ -147,6 +148,13 @@ export default function NavSidebar({
|
||||||
{ intervalMs: 30000, enabled: !me.is_demo }
|
{ intervalMs: 30000, enabled: !me.is_demo }
|
||||||
);
|
);
|
||||||
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
||||||
|
// Download center badge = items still working (queued/running/paused). Reuses the same
|
||||||
|
// lightweight per-user index the feed cards poll, so no extra request shape.
|
||||||
|
const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, {
|
||||||
|
intervalMs: 30000,
|
||||||
|
enabled: !me.is_demo,
|
||||||
|
});
|
||||||
|
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
||||||
|
|
||||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
||||||
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
||||||
|
|
@ -159,6 +167,11 @@ export default function NavSidebar({
|
||||||
...(me.is_demo
|
...(me.is_demo
|
||||||
? []
|
? []
|
||||||
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
|
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
|
||||||
|
// Download center — YouTube → server → your device. Hidden for the shared demo account
|
||||||
|
// (spends server disk + needs a real identity).
|
||||||
|
...(me.is_demo
|
||||||
|
? []
|
||||||
|
: [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]),
|
||||||
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
|
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
|
||||||
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
|
|
@ -630,6 +631,13 @@ export default function PlayerModal({
|
||||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!navigated && (
|
||||||
|
<DownloadButton
|
||||||
|
videoId={active.id}
|
||||||
|
title={active.title}
|
||||||
|
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setWatched(!watched)}
|
onClick={() => setWatched(!watched)}
|
||||||
|
|
|
||||||
215
frontend/src/components/ProfileEditor.tsx
Normal file
215
frontend/src/components/ProfileEditor.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||||
|
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||||
|
|
||||||
|
const BLANK: DownloadSpec = {
|
||||||
|
mode: "av",
|
||||||
|
max_height: 1080,
|
||||||
|
container: "mp4",
|
||||||
|
audio_format: "m4a",
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: false,
|
||||||
|
embed_chapters: true,
|
||||||
|
embed_thumbnail: true,
|
||||||
|
sponsorblock: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Check({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
|
||||||
|
const profiles = profilesQ.data ?? [];
|
||||||
|
const builtins = profiles.filter((p) => p.is_builtin);
|
||||||
|
const mine = profiles.filter((p) => !p.is_builtin);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<DownloadProfile | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] });
|
||||||
|
const startNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setName("");
|
||||||
|
setSpec(BLANK);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
const startEdit = (p: DownloadProfile) => {
|
||||||
|
setEditing(p);
|
||||||
|
setName(p.name);
|
||||||
|
setSpec({ ...BLANK, ...p.spec });
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
editing
|
||||||
|
? api.updateDownloadProfile(editing.id, { name, spec })
|
||||||
|
: api.createDownloadProfile({ name, spec }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
setFormOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteDownloadProfile(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = async (p: DownloadProfile) => {
|
||||||
|
if (await confirm({ title: t("downloads.profiles.delete"), message: t("downloads.profiles.deleteConfirm", { name: p.name }), confirmLabel: t("downloads.profiles.delete"), danger: true })) {
|
||||||
|
del.mutate(p.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
|
||||||
|
const set = (k: keyof DownloadSpec) => (e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial<DownloadSpec>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
|
||||||
|
{/* Existing formats */}
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted">{t("downloads.profiles.builtin")}</div>
|
||||||
|
{builtins.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60">
|
||||||
|
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mine.length > 0 && (
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted pt-2">{t("downloads.profiles.yours")}</div>
|
||||||
|
)}
|
||||||
|
{mine.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60">
|
||||||
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
|
<button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(p)} title={t("downloads.profiles.delete")} className="p-1 rounded hover:bg-surface text-muted hover:text-red-400">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!formOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={startNew}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.mode")}</label>
|
||||||
|
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
|
||||||
|
<option value="av">{t("downloads.profiles.modeAv")}</option>
|
||||||
|
<option value="v">{t("downloads.profiles.modeV")}</option>
|
||||||
|
<option value="a">{t("downloads.profiles.modeA")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{spec.mode !== "a" ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.quality")}</label>
|
||||||
|
<select
|
||||||
|
value={spec.max_height ?? ""}
|
||||||
|
onChange={(e) => patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{HEIGHTS.map((h) => (
|
||||||
|
<option key={h ?? "best"} value={h ?? ""}>
|
||||||
|
{h ? `${h}p` : t("downloads.profiles.best")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.audioFormat")}</label>
|
||||||
|
<select value={spec.audio_format ?? "m4a"} onChange={set("audio_format")} className={inputCls}>
|
||||||
|
<option value="m4a">M4A</option>
|
||||||
|
<option value="mp3">MP3</option>
|
||||||
|
<option value="opus">Opus</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{spec.mode !== "a" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.container")}</label>
|
||||||
|
<select value={spec.container ?? "mp4"} onChange={set("container")} className={inputCls}>
|
||||||
|
<option value="mp4">MP4</option>
|
||||||
|
<option value="mkv">MKV</option>
|
||||||
|
<option value="webm">WebM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.vcodec")}</label>
|
||||||
|
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
|
||||||
|
<option value="">{t("downloads.profiles.any")}</option>
|
||||||
|
<option value="h264">H.264</option>
|
||||||
|
<option value="vp9">VP9</option>
|
||||||
|
<option value="av1">AV1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{spec.mode !== "a" && (
|
||||||
|
<Check label={t("downloads.profiles.embedSubs")} checked={spec.embed_subs} onChange={(v) => patch({ embed_subs: v })} />
|
||||||
|
)}
|
||||||
|
<Check label={t("downloads.profiles.embedChapters")} checked={spec.embed_chapters} onChange={(v) => patch({ embed_chapters: v })} />
|
||||||
|
<Check label={t("downloads.profiles.embedThumbnail")} checked={spec.embed_thumbnail} onChange={(v) => patch({ embed_thumbnail: v })} />
|
||||||
|
<Check label={t("downloads.profiles.sponsorblock")} checked={spec.sponsorblock} onChange={(v) => patch({ sponsorblock: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button onClick={() => setFormOpen(false)} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
|
||||||
|
{t("downloads.actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={!name.trim() || save.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
|
import DownloadButton from "./DownloadButton";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
@ -67,6 +68,7 @@ function Actions({
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<AddToPlaylist videoId={video.id} />
|
<AddToPlaylist videoId={video.id} />
|
||||||
|
<DownloadButton videoId={video.id} title={video.title} />
|
||||||
<button
|
<button
|
||||||
onClick={act("hidden")}
|
onClick={act("hidden")}
|
||||||
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||||
|
|
|
||||||
136
frontend/src/i18n/locales/de/downloads.json
Normal file
136
frontend/src/i18n/locales/de/downloads.json
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
{
|
||||||
|
"navLabel": "Downloads",
|
||||||
|
"button": {
|
||||||
|
"label": "Herunterladen",
|
||||||
|
"queued": "In Warteschlange",
|
||||||
|
"downloaded": "Heruntergeladen"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"title": "Video herunterladen",
|
||||||
|
"profile": "Format",
|
||||||
|
"nameLabel": "Dateiname (optional)",
|
||||||
|
"namePlaceholder": "Leer lassen, um den Videotitel zu verwenden",
|
||||||
|
"submit": "Zu Downloads hinzufügen",
|
||||||
|
"added": "Zu Downloads hinzugefügt",
|
||||||
|
"view": "Öffnen",
|
||||||
|
"manage": "Formate verwalten"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Downloads",
|
||||||
|
"subtitle": "Videos auf den Server laden und dann auf dein Gerät speichern.",
|
||||||
|
"addUrl": "YouTube-Link oder Video-ID einfügen…",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"manage": "Formate verwalten"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"queue": "Warteschlange",
|
||||||
|
"done": "Bibliothek",
|
||||||
|
"shared": "Mit mir geteilt",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"queued": "Wartet",
|
||||||
|
"running": "Lädt",
|
||||||
|
"paused": "Pausiert",
|
||||||
|
"done": "Fertig",
|
||||||
|
"error": "Fehlgeschlagen",
|
||||||
|
"canceled": "Abgebrochen",
|
||||||
|
"expired": "Abgelaufen"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Fortsetzen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Entfernen",
|
||||||
|
"retry": "Erneut",
|
||||||
|
"download": "Auf mein Gerät speichern",
|
||||||
|
"rename": "Umbenennen",
|
||||||
|
"share": "Teilen"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"queue": "Die Warteschlange ist leer.",
|
||||||
|
"done": "Noch keine Downloads.",
|
||||||
|
"shared": "Es wurde nichts mit dir geteilt.",
|
||||||
|
"admin": "Auf dieser Instanz gibt es noch keine Downloads."
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "Dein Speicher",
|
||||||
|
"items": "{{used}} / {{max}} Elemente",
|
||||||
|
"unlimited": "Unbegrenzt"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"title": "Download umbenennen",
|
||||||
|
"label": "Anzeigename",
|
||||||
|
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
|
||||||
|
"save": "Speichern"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Download teilen",
|
||||||
|
"label": "E-Mail des Empfängers",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"submit": "Teilen",
|
||||||
|
"done": "Geteilt mit {{email}}"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"cancelTitle": "Download abbrechen?",
|
||||||
|
"cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.",
|
||||||
|
"deleteTitle": "Download entfernen?",
|
||||||
|
"deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben."
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"manage": "Formate verwalten",
|
||||||
|
"title": "Download-Formate",
|
||||||
|
"builtin": "Integriert",
|
||||||
|
"yours": "Deine Formate",
|
||||||
|
"new": "Neues Format",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Mein Format",
|
||||||
|
"mode": "Inhalt",
|
||||||
|
"modeAv": "Video + Audio",
|
||||||
|
"modeV": "Nur Video",
|
||||||
|
"modeA": "Nur Audio",
|
||||||
|
"quality": "Max. Qualität",
|
||||||
|
"best": "Beste",
|
||||||
|
"container": "Container",
|
||||||
|
"audioFormat": "Audioformat",
|
||||||
|
"vcodec": "Video-Codec",
|
||||||
|
"any": "Beliebig",
|
||||||
|
"embedSubs": "Untertitel einbetten",
|
||||||
|
"embedChapters": "Kapitel einbetten",
|
||||||
|
"embedThumbnail": "Vorschaubild einbetten",
|
||||||
|
"sponsorblock": "Sponsor-Segmente überspringen (SponsorBlock)",
|
||||||
|
"save": "Speichern",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"deleteConfirm": "Das Format „{{name}}“ löschen?"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"storageTitle": "Speicher",
|
||||||
|
"readyFiles": "Fertige Dateien",
|
||||||
|
"totalSize": "Gesamtgröße",
|
||||||
|
"cap": "Cache-Limit",
|
||||||
|
"noCap": "kein Limit",
|
||||||
|
"perUser": "Speicher pro Nutzer",
|
||||||
|
"user": "Nutzer",
|
||||||
|
"footprint": "Belegung",
|
||||||
|
"editQuota": "Kontingent bearbeiten",
|
||||||
|
"quotaTitle": "Download-Kontingent — {{email}}",
|
||||||
|
"maxBytes": "Speicherlimit (GB)",
|
||||||
|
"maxJobs": "Max. Downloads",
|
||||||
|
"maxConcurrent": "Max. gleichzeitig",
|
||||||
|
"unlimited": "Unbegrenzt (Speicherlimit umgehen)",
|
||||||
|
"reset": "Auf Standard zurücksetzen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"custom": "individuell",
|
||||||
|
"default": "Standard"
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"title": "Titel",
|
||||||
|
"channel": "Kanal",
|
||||||
|
"format": "Format",
|
||||||
|
"size": "Größe",
|
||||||
|
"status": "Status",
|
||||||
|
"added": "Hinzugefügt",
|
||||||
|
"user": "Nutzer"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
frontend/src/i18n/locales/en/downloads.json
Normal file
136
frontend/src/i18n/locales/en/downloads.json
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
{
|
||||||
|
"navLabel": "Downloads",
|
||||||
|
"button": {
|
||||||
|
"label": "Download",
|
||||||
|
"queued": "In queue",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"title": "Download video",
|
||||||
|
"profile": "Format",
|
||||||
|
"nameLabel": "File name (optional)",
|
||||||
|
"namePlaceholder": "Leave blank to use the video title",
|
||||||
|
"submit": "Add to downloads",
|
||||||
|
"added": "Added to downloads",
|
||||||
|
"view": "View",
|
||||||
|
"manage": "Manage formats"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Downloads",
|
||||||
|
"subtitle": "Download videos to the server, then save them to your device.",
|
||||||
|
"addUrl": "Paste a YouTube link or video id…",
|
||||||
|
"add": "Add",
|
||||||
|
"manage": "Manage formats"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"queue": "Queue",
|
||||||
|
"done": "Library",
|
||||||
|
"shared": "Shared with me",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"queued": "Queued",
|
||||||
|
"running": "Downloading",
|
||||||
|
"paused": "Paused",
|
||||||
|
"done": "Ready",
|
||||||
|
"error": "Failed",
|
||||||
|
"canceled": "Canceled",
|
||||||
|
"expired": "Expired"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Resume",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Remove",
|
||||||
|
"retry": "Retry",
|
||||||
|
"download": "Save to my device",
|
||||||
|
"rename": "Rename",
|
||||||
|
"share": "Share"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"queue": "Nothing in the queue.",
|
||||||
|
"done": "No downloads yet.",
|
||||||
|
"shared": "Nothing has been shared with you.",
|
||||||
|
"admin": "No downloads on this instance yet."
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "Your storage",
|
||||||
|
"items": "{{used}} / {{max}} items",
|
||||||
|
"unlimited": "Unlimited"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"title": "Rename download",
|
||||||
|
"label": "Display name",
|
||||||
|
"hint": "Only changes the name you see and download with — the stored file keeps its own name.",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Share download",
|
||||||
|
"label": "Recipient email",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"submit": "Share",
|
||||||
|
"done": "Shared with {{email}}"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"cancelTitle": "Cancel download?",
|
||||||
|
"cancelBody": "This stops the download and removes it from your queue.",
|
||||||
|
"deleteTitle": "Remove download?",
|
||||||
|
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires."
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"manage": "Manage formats",
|
||||||
|
"title": "Download formats",
|
||||||
|
"builtin": "Built-in",
|
||||||
|
"yours": "Your formats",
|
||||||
|
"new": "New format",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "My format",
|
||||||
|
"mode": "Content",
|
||||||
|
"modeAv": "Video + audio",
|
||||||
|
"modeV": "Video only",
|
||||||
|
"modeA": "Audio only",
|
||||||
|
"quality": "Max quality",
|
||||||
|
"best": "Best",
|
||||||
|
"container": "Container",
|
||||||
|
"audioFormat": "Audio format",
|
||||||
|
"vcodec": "Video codec",
|
||||||
|
"any": "Any",
|
||||||
|
"embedSubs": "Embed subtitles",
|
||||||
|
"embedChapters": "Embed chapters",
|
||||||
|
"embedThumbnail": "Embed thumbnail",
|
||||||
|
"sponsorblock": "Skip sponsor segments (SponsorBlock)",
|
||||||
|
"save": "Save",
|
||||||
|
"create": "Create",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Delete the format “{{name}}”?"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"storageTitle": "Storage",
|
||||||
|
"readyFiles": "Ready files",
|
||||||
|
"totalSize": "Total size",
|
||||||
|
"cap": "Cache cap",
|
||||||
|
"noCap": "no cap",
|
||||||
|
"perUser": "Per-user footprint",
|
||||||
|
"user": "User",
|
||||||
|
"footprint": "Footprint",
|
||||||
|
"editQuota": "Edit quota",
|
||||||
|
"quotaTitle": "Download quota — {{email}}",
|
||||||
|
"maxBytes": "Storage limit (GB)",
|
||||||
|
"maxJobs": "Max downloads",
|
||||||
|
"maxConcurrent": "Max concurrent",
|
||||||
|
"unlimited": "Unlimited (bypass storage limit)",
|
||||||
|
"reset": "Reset to default",
|
||||||
|
"save": "Save",
|
||||||
|
"custom": "custom",
|
||||||
|
"default": "default"
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"title": "Title",
|
||||||
|
"channel": "Channel",
|
||||||
|
"format": "Format",
|
||||||
|
"size": "Size",
|
||||||
|
"status": "Status",
|
||||||
|
"added": "Added",
|
||||||
|
"user": "User"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
frontend/src/i18n/locales/hu/downloads.json
Normal file
136
frontend/src/i18n/locales/hu/downloads.json
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
{
|
||||||
|
"navLabel": "Letöltések",
|
||||||
|
"button": {
|
||||||
|
"label": "Letöltés",
|
||||||
|
"queued": "Sorban",
|
||||||
|
"downloaded": "Letöltve"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"title": "Videó letöltése",
|
||||||
|
"profile": "Formátum",
|
||||||
|
"nameLabel": "Fájlnév (opcionális)",
|
||||||
|
"namePlaceholder": "Üresen hagyva a videó címét használja",
|
||||||
|
"submit": "Hozzáadás a letöltésekhez",
|
||||||
|
"added": "Hozzáadva a letöltésekhez",
|
||||||
|
"view": "Megnyitás",
|
||||||
|
"manage": "Formátumok kezelése"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Letöltések",
|
||||||
|
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
|
||||||
|
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||||
|
"add": "Hozzáad",
|
||||||
|
"manage": "Formátumok kezelése"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"queue": "Sor",
|
||||||
|
"done": "Könyvtár",
|
||||||
|
"shared": "Velem megosztva",
|
||||||
|
"system": "Rendszer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"queued": "Várakozik",
|
||||||
|
"running": "Letöltés",
|
||||||
|
"paused": "Szüneteltetve",
|
||||||
|
"done": "Kész",
|
||||||
|
"error": "Sikertelen",
|
||||||
|
"canceled": "Megszakítva",
|
||||||
|
"expired": "Lejárt"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pause": "Szünet",
|
||||||
|
"resume": "Folytatás",
|
||||||
|
"cancel": "Megszakítás",
|
||||||
|
"delete": "Eltávolítás",
|
||||||
|
"retry": "Újra",
|
||||||
|
"download": "Mentés a gépemre",
|
||||||
|
"rename": "Átnevezés",
|
||||||
|
"share": "Megosztás"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"queue": "A sor üres.",
|
||||||
|
"done": "Még nincs letöltés.",
|
||||||
|
"shared": "Még nem osztottak meg veled semmit.",
|
||||||
|
"admin": "Ezen a példányon még nincs letöltés."
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "Tárhelyed",
|
||||||
|
"items": "{{used}} / {{max}} elem",
|
||||||
|
"unlimited": "Korlátlan"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"title": "Letöltés átnevezése",
|
||||||
|
"label": "Megjelenített név",
|
||||||
|
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
|
||||||
|
"save": "Mentés"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Letöltés megosztása",
|
||||||
|
"label": "Címzett e-mail címe",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"submit": "Megosztás",
|
||||||
|
"done": "Megosztva vele: {{email}}"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"cancelTitle": "Megszakítod a letöltést?",
|
||||||
|
"cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.",
|
||||||
|
"deleteTitle": "Eltávolítod a letöltést?",
|
||||||
|
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára."
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"manage": "Formátumok kezelése",
|
||||||
|
"title": "Letöltési formátumok",
|
||||||
|
"builtin": "Beépített",
|
||||||
|
"yours": "Saját formátumaid",
|
||||||
|
"new": "Új formátum",
|
||||||
|
"name": "Név",
|
||||||
|
"namePlaceholder": "Saját formátum",
|
||||||
|
"mode": "Tartalom",
|
||||||
|
"modeAv": "Videó + hang",
|
||||||
|
"modeV": "Csak videó",
|
||||||
|
"modeA": "Csak hang",
|
||||||
|
"quality": "Max. minőség",
|
||||||
|
"best": "Legjobb",
|
||||||
|
"container": "Konténer",
|
||||||
|
"audioFormat": "Hangformátum",
|
||||||
|
"vcodec": "Videó codec",
|
||||||
|
"any": "Bármelyik",
|
||||||
|
"embedSubs": "Feliratok beágyazása",
|
||||||
|
"embedChapters": "Fejezetek beágyazása",
|
||||||
|
"embedThumbnail": "Bélyegkép beágyazása",
|
||||||
|
"sponsorblock": "Szponzorált szakaszok kihagyása (SponsorBlock)",
|
||||||
|
"save": "Mentés",
|
||||||
|
"create": "Létrehozás",
|
||||||
|
"delete": "Törlés",
|
||||||
|
"deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"storageTitle": "Tárhely",
|
||||||
|
"readyFiles": "Kész fájlok",
|
||||||
|
"totalSize": "Teljes méret",
|
||||||
|
"cap": "Gyorsítótár-korlát",
|
||||||
|
"noCap": "nincs korlát",
|
||||||
|
"perUser": "Felhasználónkénti tárhelyhasználat",
|
||||||
|
"user": "Felhasználó",
|
||||||
|
"footprint": "Használat",
|
||||||
|
"editQuota": "Kvóta szerkesztése",
|
||||||
|
"quotaTitle": "Letöltési kvóta — {{email}}",
|
||||||
|
"maxBytes": "Tárhelykorlát (GB)",
|
||||||
|
"maxJobs": "Max. letöltés",
|
||||||
|
"maxConcurrent": "Max. egyidejű",
|
||||||
|
"unlimited": "Korlátlan (tárhelykorlát kikapcsolása)",
|
||||||
|
"reset": "Visszaállítás alapértékre",
|
||||||
|
"save": "Mentés",
|
||||||
|
"custom": "egyedi",
|
||||||
|
"default": "alapértelmezett"
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"title": "Cím",
|
||||||
|
"channel": "Csatorna",
|
||||||
|
"format": "Formátum",
|
||||||
|
"size": "Méret",
|
||||||
|
"status": "Állapot",
|
||||||
|
"added": "Hozzáadva",
|
||||||
|
"user": "Felhasználó"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -623,6 +623,99 @@ export interface AdminUserRow {
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- download center ---
|
||||||
|
export interface DownloadSpec {
|
||||||
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
|
max_height: number | null;
|
||||||
|
container: string | null;
|
||||||
|
audio_format: string | null;
|
||||||
|
vcodec: string | null;
|
||||||
|
embed_subs: boolean;
|
||||||
|
embed_chapters: boolean;
|
||||||
|
embed_thumbnail: boolean;
|
||||||
|
sponsorblock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadProfile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
spec: DownloadSpec;
|
||||||
|
is_builtin: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadAsset {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
title: string | null;
|
||||||
|
uploader: string | null;
|
||||||
|
upload_date: string | null;
|
||||||
|
duration_s: number | null;
|
||||||
|
size_bytes: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
container: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadStatus =
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "paused"
|
||||||
|
| "done"
|
||||||
|
| "error"
|
||||||
|
| "canceled";
|
||||||
|
|
||||||
|
export interface DownloadJob {
|
||||||
|
id: number;
|
||||||
|
status: DownloadStatus;
|
||||||
|
progress: number;
|
||||||
|
speed_bps: number | null;
|
||||||
|
eta_s: number | null;
|
||||||
|
phase: string | null;
|
||||||
|
error: string | null;
|
||||||
|
queue_pos: number;
|
||||||
|
created_at: string | null;
|
||||||
|
source_kind: string;
|
||||||
|
source_ref: string;
|
||||||
|
profile_id: number | null;
|
||||||
|
spec: DownloadSpec;
|
||||||
|
display_name: string | null;
|
||||||
|
can_download: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
asset: DownloadAsset | null;
|
||||||
|
shared?: boolean;
|
||||||
|
user_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadUsage {
|
||||||
|
footprint_bytes: number;
|
||||||
|
active_jobs: number;
|
||||||
|
running_jobs: number;
|
||||||
|
max_bytes: number;
|
||||||
|
max_concurrent: number;
|
||||||
|
max_jobs: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStorage {
|
||||||
|
ready_files: number;
|
||||||
|
total_bytes: number;
|
||||||
|
total_assets: number;
|
||||||
|
total_cap_bytes: number;
|
||||||
|
per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDownloadQuota {
|
||||||
|
user_id: number;
|
||||||
|
custom: boolean;
|
||||||
|
max_bytes: number;
|
||||||
|
max_concurrent: number;
|
||||||
|
max_jobs: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||||
|
|
@ -914,6 +1007,57 @@ export const api = {
|
||||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||||
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// --- download center ---
|
||||||
|
downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
|
||||||
|
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
||||||
|
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
||||||
|
updateDownloadProfile: (
|
||||||
|
id: number,
|
||||||
|
patch: { name?: string; spec?: DownloadSpec }
|
||||||
|
): Promise<DownloadProfile> =>
|
||||||
|
req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
deleteDownloadProfile: (id: number) =>
|
||||||
|
req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
enqueueDownload: (body: {
|
||||||
|
source: string;
|
||||||
|
profile_id?: number;
|
||||||
|
spec?: DownloadSpec;
|
||||||
|
display_name?: string;
|
||||||
|
}): Promise<DownloadJob> =>
|
||||||
|
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
||||||
|
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
|
||||||
|
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
|
||||||
|
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
|
||||||
|
pauseDownload: (id: number): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
|
||||||
|
resumeDownload: (id: number): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
|
||||||
|
cancelDownload: (id: number): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
|
||||||
|
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
|
||||||
|
renameDownload: (id: number, display_name: string): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }),
|
||||||
|
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
|
||||||
|
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
unshareDownload: (id: number, email: string) =>
|
||||||
|
req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
|
||||||
|
downloadFileUrl: (id: number): string => `/api/downloads/${id}/file`,
|
||||||
|
|
||||||
|
// admin
|
||||||
|
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
||||||
|
adminDownloadStorage: (): Promise<AdminStorage> => req("/api/admin/downloads/storage"),
|
||||||
|
adminGetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
||||||
|
req(`/api/admin/downloads/quota/${userId}`),
|
||||||
|
adminSetDownloadQuota: (
|
||||||
|
userId: number,
|
||||||
|
patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
|
||||||
|
): Promise<AdminDownloadQuota> =>
|
||||||
|
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
|
||||||
|
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
||||||
|
req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { HttpError };
|
export { HttpError };
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,26 @@ export function relativeTime(iso: string | null): string {
|
||||||
return relativeFromMs(new Date(iso).getTime());
|
return relativeFromMs(new Date(iso).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
|
||||||
|
export function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
const n = Number(bytes);
|
||||||
|
if (!n || n < 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let i = 0;
|
||||||
|
let v = n;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
||||||
|
export function formatSpeed(bps: number | null | undefined): string {
|
||||||
|
if (!bps) return "";
|
||||||
|
return `${formatBytes(bps)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
||||||
* client-side notifications whose timestamps are already numbers). One implementation, one
|
* client-side notifications whose timestamps are already numbers). One implementation, one
|
||||||
* set of `time.*` strings — shared instead of re-implemented per component. */
|
* set of `time.*` strings — shared instead of re-implemented per component. */
|
||||||
|
|
|
||||||
14
frontend/src/lib/nav.ts
Normal file
14
frontend/src/lib/nav.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
|
// Tiny decoupled navigator so deep components (a feed card's download toast, the Download
|
||||||
|
// Center) can switch pages without threading setPage through the whole tree. App registers
|
||||||
|
// its setPage on mount; navigateTo is a no-op until then.
|
||||||
|
let _navigate: ((p: Page) => void) | null = null;
|
||||||
|
|
||||||
|
export function setNavigator(fn: (p: Page) => void): void {
|
||||||
|
_navigate = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateTo(p: Page): void {
|
||||||
|
_navigate?.(p);
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,7 @@ export const PAGES = [
|
||||||
"users",
|
"users",
|
||||||
"notifications",
|
"notifications",
|
||||||
"messages",
|
"messages",
|
||||||
|
"downloads",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Page = (typeof PAGES)[number];
|
export type Page = (typeof PAGES)[number];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue