Release: onboarding auto-import, per-user header, channel status filter, stable priority, Phase B security fixes
This commit is contained in:
commit
d791d577a0
6 changed files with 224 additions and 26 deletions
|
|
@ -21,7 +21,7 @@ import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import Feed from "./components/Feed";
|
import Feed from "./components/Feed";
|
||||||
import Channels from "./components/Channels";
|
import Channels, { type ChannelStatusFilter } from "./components/Channels";
|
||||||
import Stats from "./components/Stats";
|
import Stats from "./components/Stats";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
|
|
@ -64,6 +64,7 @@ export default function App() {
|
||||||
const [page, setPageState] = useState<Page>(readPage);
|
const [page, setPageState] = useState<Page>(readPage);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
||||||
|
|
||||||
function setFilters(next: FeedFilters) {
|
function setFilters(next: FeedFilters) {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
|
|
@ -157,6 +158,10 @@ export default function App() {
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
onGoToFullHistory={() => {
|
||||||
|
setChannelFilter("needs_full");
|
||||||
|
setPage("channels");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{page === "feed" && (
|
{page === "feed" && (
|
||||||
|
|
@ -171,6 +176,8 @@ export default function App() {
|
||||||
{page === "channels" ? (
|
{page === "channels" ? (
|
||||||
<Channels
|
<Channels
|
||||||
canWrite={meQuery.data!.can_write}
|
canWrite={meQuery.data!.can_write}
|
||||||
|
statusFilter={channelFilter}
|
||||||
|
setStatusFilter={setChannelFilter}
|
||||||
onViewChannel={(id, name) => {
|
onViewChannel={(id, name) => {
|
||||||
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
||||||
setPage("feed");
|
setPage("feed");
|
||||||
|
|
@ -179,7 +186,13 @@ export default function App() {
|
||||||
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
||||||
<Stats />
|
<Stats />
|
||||||
) : (
|
) : (
|
||||||
<Feed filters={filters} setFilters={setFilters} view={view} />
|
<Feed
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
view={view}
|
||||||
|
canRead={meQuery.data!.can_read}
|
||||||
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,25 @@ import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
|
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { id: ChannelStatusFilter; label: string }[] = [
|
||||||
|
{ id: "all", label: "All" },
|
||||||
|
{ id: "needs_full", label: "Needs full history" },
|
||||||
|
{ id: "fully_synced", label: "Fully synced" },
|
||||||
|
{ id: "hidden", label: "Hidden" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Channels({
|
export default function Channels({
|
||||||
canWrite,
|
canWrite,
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
onViewChannel: (id: string, name: string) => void;
|
onViewChannel: (id: string, name: string) => void;
|
||||||
|
statusFilter: ChannelStatusFilter;
|
||||||
|
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
|
|
@ -54,6 +67,19 @@ export default function Channels({
|
||||||
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
|
||||||
|
// doesn't jump (the server list is priority-sorted; refetching would reorder rows and
|
||||||
|
// lose your scroll position). The new order takes effect next time the page loads.
|
||||||
|
const bumpPriority = (id: string, current: number, delta: number) => {
|
||||||
|
const next = current + delta;
|
||||||
|
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
|
||||||
|
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch))
|
||||||
|
);
|
||||||
|
api
|
||||||
|
.updateChannel(id, { priority: next })
|
||||||
|
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
||||||
|
};
|
||||||
|
|
||||||
const attach = useMutation({
|
const attach = useMutation({
|
||||||
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
|
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||||
|
|
@ -103,8 +129,16 @@ export default function Channels({
|
||||||
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
|
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const channels = (channelsQuery.data ?? []).filter(
|
const channels = (channelsQuery.data ?? [])
|
||||||
(c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())
|
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()))
|
||||||
|
.filter((c) =>
|
||||||
|
statusFilter === "needs_full"
|
||||||
|
? !c.backfill_done
|
||||||
|
: statusFilter === "fully_synced"
|
||||||
|
? c.backfill_done
|
||||||
|
: statusFilter === "hidden"
|
||||||
|
? c.hidden
|
||||||
|
: true
|
||||||
);
|
);
|
||||||
const s = statusQuery.data;
|
const s = statusQuery.data;
|
||||||
|
|
||||||
|
|
@ -179,6 +213,23 @@ export default function Channels({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => setStatusFilter(f.id)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
||||||
|
statusFilter === f.id
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||||
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
|
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
|
||||||
by “Channel priority”, attach your own <b className="text-fg/80">tags</b> to filter the feed,
|
by “Channel priority”, attach your own <b className="text-fg/80">tags</b> to filter the feed,
|
||||||
|
|
@ -244,7 +295,7 @@ export default function Channels({
|
||||||
unsubscribe.mutate(c.id);
|
unsubscribe.mutate(c.id);
|
||||||
}}
|
}}
|
||||||
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
|
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
|
||||||
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
|
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
|
||||||
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
||||||
onDeep={() =>
|
onDeep={() =>
|
||||||
patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
|
patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,14 @@ export default function Feed({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
view,
|
view,
|
||||||
|
canRead,
|
||||||
|
onOpenWizard,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
|
canRead: boolean;
|
||||||
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
||||||
|
|
@ -125,8 +129,26 @@ export default function Feed({
|
||||||
|
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
||||||
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
||||||
if (items.length === 0)
|
if (items.length === 0) {
|
||||||
|
if (!canRead)
|
||||||
|
return (
|
||||||
|
<div className="p-8 grid place-items-center text-center">
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<h2 className="text-lg font-semibold">Your feed is empty</h2>
|
||||||
|
<p className="text-sm text-muted mt-2 mb-4">
|
||||||
|
Connect your YouTube account to import your subscriptions and build your feed.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onOpenWizard}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Set up my feed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default function Header({
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
|
|
@ -20,6 +21,7 @@ export default function Header({
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (p: Page) => void;
|
setPage: (p: Page) => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
|
@ -36,7 +38,7 @@ export default function Header({
|
||||||
Sift<span className="text-accent">lode</span>
|
Sift<span className="text-accent">lode</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SyncStatus />
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
||||||
|
|
||||||
{page === "feed" ? (
|
{page === "feed" ? (
|
||||||
<div className="flex-1 max-w-xl mx-auto relative">
|
<div className="flex-1 max-w-xl mx-auto relative">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Me } from "../lib/api";
|
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||||
|
import { api, type Me } from "../lib/api";
|
||||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||||
|
|
||||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||||
|
|
@ -10,6 +11,8 @@ import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||||
//
|
//
|
||||||
// The visible step is derived from what's already granted, so the wizard resumes correctly
|
// The visible step is derived from what's already granted, so the wizard resumes correctly
|
||||||
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
|
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
|
||||||
|
// Right after read is granted, it auto-imports the user's subscriptions so they land on a
|
||||||
|
// populated feed (channels already in the shared catalog appear instantly).
|
||||||
|
|
||||||
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
||||||
function ConsentHeadsUp() {
|
function ConsentHeadsUp() {
|
||||||
|
|
@ -52,6 +55,42 @@ function Dots({ index, total }: { index: number; total: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const importTriggered = useRef(false);
|
||||||
|
|
||||||
|
// Once we have read access, find out whether any subscriptions are imported yet.
|
||||||
|
const myStatus = useQuery({
|
||||||
|
queryKey: ["my-status"],
|
||||||
|
queryFn: api.myStatus,
|
||||||
|
enabled: me.can_read,
|
||||||
|
});
|
||||||
|
|
||||||
|
const importSubs = useMutation({
|
||||||
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Feed/channels/status now have content — refresh the app behind the wizard.
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// First time we see read access with zero imported channels, pull the user's
|
||||||
|
// subscriptions automatically so they don't have to hunt for a button.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
me.can_read &&
|
||||||
|
myStatus.data &&
|
||||||
|
myStatus.data.channels_total === 0 &&
|
||||||
|
!importTriggered.current
|
||||||
|
) {
|
||||||
|
importTriggered.current = true;
|
||||||
|
importSubs.mutate();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [me.can_read, myStatus.data?.channels_total]);
|
||||||
|
|
||||||
// Closing without finishing the essential read step suppresses future auto-popups
|
// Closing without finishing the essential read step suppresses future auto-popups
|
||||||
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|
@ -68,6 +107,15 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [me.can_read]);
|
}, [me.can_read]);
|
||||||
|
|
||||||
|
// Are we currently importing (or about to)? Show a progress screen instead of the
|
||||||
|
// "you're connected" step until the first import settles.
|
||||||
|
const importing =
|
||||||
|
me.can_read &&
|
||||||
|
!importSubs.isError &&
|
||||||
|
(importSubs.isPending ||
|
||||||
|
(importTriggered.current && !importSubs.isSuccess) ||
|
||||||
|
(myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
|
||||||
|
|
||||||
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
||||||
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
||||||
|
|
||||||
|
|
@ -112,18 +160,53 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "write" && (
|
{/* Read granted: show import progress before the optional write step. */}
|
||||||
|
{me.can_read && importing && (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold">Building your feed…</h2>
|
||||||
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
||||||
|
Importing your YouTube subscriptions. Channels already in Siftlode show up
|
||||||
|
instantly; any new ones fill in automatically in the background.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "write" && !importing && (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
<Check className="w-6 h-6" />
|
<Check className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold">You're connected</h2>
|
<h2 className="text-lg font-semibold">You're connected</h2>
|
||||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||||
Your feed is ready. Optionally, you can let Siftlode{" "}
|
{importSubs.isError ? (
|
||||||
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you —
|
<>Your YouTube account is connected, but importing subscriptions failed — you
|
||||||
this needs an extra write permission. Skip it to stay read-only; you can always
|
can retry from Settings → Sync. </>
|
||||||
enable it later in Settings.
|
) : (
|
||||||
|
<>
|
||||||
|
Your feed is ready
|
||||||
|
{myStatus.data ? ` (${myStatus.data.channels_total} channels)` : ""}. Optionally,
|
||||||
|
let Siftlode{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!importSubs.isError && (
|
||||||
|
<>
|
||||||
|
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you
|
||||||
|
— this needs an extra write permission. Skip it to stay read-only; you can
|
||||||
|
always enable it later in Settings.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{importSubs.isError && (
|
||||||
|
<button
|
||||||
|
onClick={() => importSubs.mutate()}
|
||||||
|
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" /> Retry import
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<ConsentHeadsUp />
|
<ConsentHeadsUp />
|
||||||
<div className="mt-5 flex flex-col gap-2">
|
<div className="mt-5 flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -145,7 +228,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "done" && (
|
{step === "done" && !importing && (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
<Check className="w-6 h-6" />
|
<Check className="w-6 h-6" />
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,67 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Database, Loader2, Pause, Play } from "lucide-react";
|
import { Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { formatViews } from "../lib/format";
|
import { formatViews } from "../lib/format";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
export default function SyncStatus() {
|
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
||||||
|
// user, how many of *their* channels are still being fetched, and how many lack full
|
||||||
|
// history (clickable -> channel manager filtered to those). The pause control is admin-only.
|
||||||
|
export default function SyncStatus({
|
||||||
|
isAdmin,
|
||||||
|
onGoToFullHistory,
|
||||||
|
}: {
|
||||||
|
isAdmin: boolean;
|
||||||
|
onGoToFullHistory: () => void;
|
||||||
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["sync-status"],
|
queryKey: ["my-status"],
|
||||||
queryFn: api.status,
|
queryFn: api.myStatus,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
staleTime: 25_000,
|
staleTime: 25_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["sync-status"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
const syncing = data.channels_recent_pending;
|
||||||
|
const notFull = data.channels_deep_pending; // your channels without full history yet
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
||||||
<Database className="w-3.5 h-3.5" />
|
<Database className="w-3.5 h-3.5" />
|
||||||
<span>{formatViews(data.videos_total)} videos</span>
|
<span>{formatViews(data.my_videos)} videos</span>
|
||||||
<span className="opacity-40">·</span>
|
<span className="opacity-40">·</span>
|
||||||
{data.paused ? (
|
{data.paused ? (
|
||||||
<span className="text-accent font-medium">paused</span>
|
<span className="text-accent font-medium">paused</span>
|
||||||
) : data.channels_backfilling > 0 ? (
|
) : syncing > 0 ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
{data.channels_backfilling} syncing
|
{syncing} syncing
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>all synced</span>
|
<span>all synced</span>
|
||||||
)}
|
)}
|
||||||
{data.is_admin && (
|
{notFull > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<Tooltip hint="Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.">
|
||||||
|
<button
|
||||||
|
onClick={onGoToFullHistory}
|
||||||
|
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<History className="w-3.5 h-3.5" />
|
||||||
|
{notFull} without full history
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle.mutate()}
|
onClick={() => toggle.mutate()}
|
||||||
disabled={toggle.isPending}
|
disabled={toggle.isPending}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue