Merge: channel status filter, header full-history link, stable priority

This commit is contained in:
npeter83 2026-06-14 07:08:59 +02:00
commit 72409f79bc
4 changed files with 92 additions and 10 deletions

View file

@ -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");

View file

@ -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,9 +129,17 @@ 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;
return ( return (
@ -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 } })

View file

@ -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 isAdmin={me.role === "admin"} /> <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">

View file

@ -1,12 +1,19 @@
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";
// Per-user status (not the global catalog): shows the number of videos available to *this* // Per-user status (not the global catalog): shows the number of videos available to *this*
// user and how many of *their* channels are still being fetched. The pause control is // user, how many of *their* channels are still being fetched, and how many lack full
// admin-only (it pauses the shared background sync). // history (clickable -> channel manager filtered to those). The pause control is admin-only.
export default function SyncStatus({ isAdmin }: { isAdmin: boolean }) { export default function SyncStatus({
isAdmin,
onGoToFullHistory,
}: {
isAdmin: boolean;
onGoToFullHistory: () => void;
}) {
const qc = useQueryClient(); const qc = useQueryClient();
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["my-status"], queryKey: ["my-status"],
@ -23,6 +30,7 @@ export default function SyncStatus({ isAdmin }: { isAdmin: boolean }) {
if (!data) return null; if (!data) return null;
const syncing = data.channels_recent_pending; 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">
@ -39,6 +47,20 @@ export default function SyncStatus({ isAdmin }: { isAdmin: boolean }) {
) : ( ) : (
<span>all synced</span> <span>all synced</span>
)} )}
{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 && ( {isAdmin && (
<button <button
onClick={() => toggle.mutate()} onClick={() => toggle.mutate()}