Merge improvement/channel-manager-1: data-table channel manager + reusable DataTable + admin reset
This commit is contained in:
commit
a007a31bbf
11 changed files with 969 additions and 259 deletions
|
|
@ -9,6 +9,7 @@ from app import quota
|
||||||
from app.auth import current_user, has_write_scope
|
from app.auth import current_user, has_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
|
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
|
||||||
|
from app.routes.admin import admin_user
|
||||||
from app.sync.runner import run_recent_backfill
|
from app.sync.runner import run_recent_backfill
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
|
|
@ -134,6 +135,30 @@ def update_channel(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{channel_id}/reset-backfill")
|
||||||
|
def reset_backfill(
|
||||||
|
channel_id: str,
|
||||||
|
user: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Admin-only reset: re-fetch this channel from scratch regardless of its current sync
|
||||||
|
state (a "reset" trigger). Clears the channel's backfill markers, opts it back into deep
|
||||||
|
backfill, and re-runs its recent pull immediately; the deep scheduler re-pages the full
|
||||||
|
back-catalog on its next run. Idempotent — videos upsert by id, so nothing duplicates."""
|
||||||
|
channel = db.get(Channel, channel_id)
|
||||||
|
if channel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
|
sub = _user_subscription(db, user, channel_id)
|
||||||
|
channel.backfill_done = False
|
||||||
|
channel.backfill_cursor = None
|
||||||
|
channel.recent_synced_at = None
|
||||||
|
sub.deep_requested = True
|
||||||
|
db.commit()
|
||||||
|
with quota.attribute(user.id, "backfill_recent"):
|
||||||
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
|
return {"id": channel_id, "reset": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/subscription")
|
@router.delete("/{channel_id}/subscription")
|
||||||
def unsubscribe(
|
def unsubscribe(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,18 @@ export default function App() {
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
|
||||||
|
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
|
||||||
|
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
|
||||||
|
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
|
||||||
|
? v
|
||||||
|
: "all";
|
||||||
|
});
|
||||||
|
// Persist the channel status chip so a reload (F5) keeps it.
|
||||||
|
const setChannelFilter = (f: ChannelStatusFilter) => {
|
||||||
|
setChannelFilterState(f);
|
||||||
|
localStorage.setItem(CHANNEL_FILTER_KEY, f);
|
||||||
|
};
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [notesOpen, setNotesOpen] = useState(false);
|
const [notesOpen, setNotesOpen] = useState(false);
|
||||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -266,6 +277,7 @@ export default function App() {
|
||||||
{page === "channels" ? (
|
{page === "channels" ? (
|
||||||
<Channels
|
<Channels
|
||||||
canWrite={meQuery.data!.can_write}
|
canWrite={meQuery.data!.can_write}
|
||||||
|
isAdmin={meQuery.data!.role === "admin"}
|
||||||
statusFilter={channelFilter}
|
statusFilter={channelFilter}
|
||||||
setStatusFilter={setChannelFilter}
|
setStatusFilter={setChannelFilter}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
History,
|
History,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -20,6 +19,7 @@ import { formatEta } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import DataTable, { type Column } from "./DataTable";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||||
|
|
@ -33,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||||
|
|
||||||
export default function Channels({
|
export default function Channels({
|
||||||
canWrite,
|
canWrite,
|
||||||
|
isAdmin,
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
onViewChannel: (id: string, name: string) => void;
|
onViewChannel: (id: string, name: string) => void;
|
||||||
statusFilter: ChannelStatusFilter;
|
statusFilter: ChannelStatusFilter;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||||
|
|
@ -64,7 +66,6 @@ export default function Channels({
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [newTag, setNewTag] = useState("");
|
const [newTag, setNewTag] = useState("");
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
|
|
@ -102,14 +103,26 @@ export default function Channels({
|
||||||
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const attach = useMutation({
|
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
||||||
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
|
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
|
||||||
});
|
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
|
||||||
const detach = useMutation({
|
(old ?? []).map((ch) =>
|
||||||
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
|
ch.id === id
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
? {
|
||||||
});
|
...ch,
|
||||||
|
tag_ids: hasTag
|
||||||
|
? ch.tag_ids.filter((x) => x !== tagId)
|
||||||
|
: [...ch.tag_ids, tagId],
|
||||||
|
}
|
||||||
|
: ch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const call = hasTag
|
||||||
|
? api.detachChannelTag(id, tagId)
|
||||||
|
: api.attachChannelTag(id, tagId);
|
||||||
|
call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
||||||
|
};
|
||||||
const syncSubs = useMutation({
|
const syncSubs = useMutation({
|
||||||
mutationFn: () => api.syncSubscriptions(),
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
onSuccess: (r: { subscriptions?: number }) => {
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
|
|
@ -138,6 +151,15 @@ export default function Channels({
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
|
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
|
||||||
});
|
});
|
||||||
|
const resetBackfill = useMutation({
|
||||||
|
mutationFn: (id: string) => api.resetChannelBackfill(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
notify({ level: "success", message: t("channels.notify.resetDone") });
|
||||||
|
},
|
||||||
|
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"),
|
||||||
|
});
|
||||||
const deepAll = useMutation({
|
const deepAll = useMutation({
|
||||||
mutationFn: () => api.deepAll(true),
|
mutationFn: () => api.deepAll(true),
|
||||||
onSuccess: (r: { updated?: number }) => {
|
onSuccess: (r: { updated?: number }) => {
|
||||||
|
|
@ -151,62 +173,184 @@ export default function Channels({
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const channels = (channelsQuery.data ?? [])
|
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||||
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()))
|
// header's "go to full history" deep-link drives it; the DataTable then handles name
|
||||||
.filter((c) =>
|
// search, tag filtering, sort and pagination over whatever the status chip leaves.
|
||||||
statusFilter === "needs_full"
|
const channels = (channelsQuery.data ?? []).filter((c) =>
|
||||||
? !c.backfill_done
|
statusFilter === "needs_full"
|
||||||
: statusFilter === "fully_synced"
|
? !c.backfill_done
|
||||||
? c.backfill_done
|
: statusFilter === "fully_synced"
|
||||||
: statusFilter === "hidden"
|
? c.backfill_done
|
||||||
? c.hidden
|
: statusFilter === "hidden"
|
||||||
: true
|
? c.hidden
|
||||||
);
|
: true
|
||||||
|
);
|
||||||
const s = statusQuery.data;
|
const s = statusQuery.data;
|
||||||
|
|
||||||
return (
|
const onView = (c: ManagedChannel) =>
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
|
||||||
{/* Per-user sync status */}
|
const onUnsub = async (c: ManagedChannel) => {
|
||||||
{s && (
|
const ok = await confirm({
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
title: t("channels.row.unsubscribeOnYoutube"),
|
||||||
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||||
<Stat
|
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||||
label={t("channels.stats.recentSynced")}
|
danger: true,
|
||||||
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
});
|
||||||
hint={t("channels.stats.recentSyncedHint")}
|
if (ok) unsubscribe.mutate(c.id);
|
||||||
/>
|
};
|
||||||
<Stat
|
const onReset = async (c: ManagedChannel) => {
|
||||||
label={t("channels.stats.fullHistory")}
|
const ok = await confirm({
|
||||||
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
title: t("channels.row.backfillThis"),
|
||||||
hint={t("channels.stats.fullHistoryHint")}
|
message: t("channels.confirmReset", { name: c.title ?? c.id }),
|
||||||
/>
|
confirmLabel: t("channels.row.backfillThis"),
|
||||||
{s.deep_pending_count > 0 && (
|
});
|
||||||
<Stat
|
if (ok) resetBackfill.mutate(c.id);
|
||||||
label={t("channels.stats.left")}
|
};
|
||||||
value={formatEta(s.deep_eta_seconds)}
|
|
||||||
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
|
|
||||||
<Stat
|
|
||||||
label={t("channels.stats.quotaLeft")}
|
|
||||||
value={s.quota_remaining_today.toLocaleString()}
|
|
||||||
hint={t("channels.stats.quotaLeftHint")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
const columns: Column<ManagedChannel>[] = [
|
||||||
<div className="flex items-center gap-2 mb-4">
|
{
|
||||||
<div className="relative flex-1">
|
key: "priority",
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
header: t("channels.cols.priority"),
|
||||||
<input
|
align: "center",
|
||||||
value={q}
|
width: "56px",
|
||||||
onChange={(e) => setQ(e.target.value)}
|
sortable: true,
|
||||||
placeholder={t("channels.filterPlaceholder")}
|
hideInCard: true,
|
||||||
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
sortValue: (c) => c.priority,
|
||||||
/>
|
render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channel",
|
||||||
|
header: t("channels.cols.channel"),
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
|
cardPrimary: true,
|
||||||
|
render: (c) => <NameCell c={c} onView={() => onView(c)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stored",
|
||||||
|
header: t("channels.cols.stored"),
|
||||||
|
align: "right",
|
||||||
|
width: "84px",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (c) => c.stored_videos,
|
||||||
|
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "subs",
|
||||||
|
header: t("channels.cols.subs"),
|
||||||
|
align: "right",
|
||||||
|
width: "92px",
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (c) => c.subscriber_count ?? -1,
|
||||||
|
render: (c) => (
|
||||||
|
<span className="text-muted tabular-nums">
|
||||||
|
{c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sync",
|
||||||
|
header: t("channels.cols.sync"),
|
||||||
|
width: "130px",
|
||||||
|
cardLabel: false,
|
||||||
|
render: (c) => <SyncCell c={c} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
header: t("channels.cols.tags"),
|
||||||
|
width: "360px",
|
||||||
|
cardLabel: false,
|
||||||
|
filter: {
|
||||||
|
kind: "multi",
|
||||||
|
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||||
|
// OR semantics: show channels carrying any of the selected tags.
|
||||||
|
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
||||||
|
},
|
||||||
|
render: (c) => (
|
||||||
|
<TagsCell
|
||||||
|
c={c}
|
||||||
|
userTags={userTags}
|
||||||
|
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: t("channels.cols.actions"),
|
||||||
|
align: "right",
|
||||||
|
width: "104px",
|
||||||
|
cardLabel: false,
|
||||||
|
render: (c) => (
|
||||||
|
<ActionsCell
|
||||||
|
c={c}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
||||||
|
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
|
||||||
|
onReset={() => onReset(c)}
|
||||||
|
onUnsubscribe={() => onUnsub(c)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||||
|
// where they act.
|
||||||
|
const statusChips = (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(f.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-7xl mx-auto">
|
||||||
|
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
|
||||||
|
now lives in the table headers). */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted">
|
||||||
|
{s && (
|
||||||
|
<>
|
||||||
|
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
||||||
|
<Stat
|
||||||
|
label={t("channels.stats.recentSynced")}
|
||||||
|
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
||||||
|
hint={t("channels.stats.recentSyncedHint")}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t("channels.stats.fullHistory")}
|
||||||
|
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||||
|
hint={t("channels.stats.fullHistoryHint")}
|
||||||
|
/>
|
||||||
|
{s.deep_pending_count > 0 && (
|
||||||
|
<Stat
|
||||||
|
label={t("channels.stats.left")}
|
||||||
|
value={formatEta(s.deep_eta_seconds)}
|
||||||
|
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
|
||||||
|
<Stat
|
||||||
|
label={t("channels.stats.quotaLeft")}
|
||||||
|
value={s.quota_remaining_today.toLocaleString()}
|
||||||
|
hint={t("channels.stats.quotaLeftHint")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
side="bottom"
|
side="bottom"
|
||||||
hint={t("channels.syncSubscriptionsHint")}
|
hint={t("channels.syncSubscriptionsHint")}
|
||||||
|
|
@ -233,23 +377,7 @@ export default function Channels({
|
||||||
{t("channels.backfillEverything")}
|
{t("channels.backfillEverything")}
|
||||||
</button>
|
</button>
|
||||||
</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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(f.labelKey)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||||
|
|
@ -300,42 +428,20 @@ export default function Channels({
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel list */}
|
{/* Channel table */}
|
||||||
{channelsQuery.isLoading ? (
|
{channelsQuery.isLoading ? (
|
||||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||||
) : channels.length === 0 ? (
|
|
||||||
<div className="text-muted py-8">{t("channels.empty")}</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1.5">
|
<DataTable
|
||||||
{channels.map((c) => (
|
rows={channels}
|
||||||
<ChannelRow
|
columns={columns}
|
||||||
key={c.id}
|
rowKey={(c) => c.id}
|
||||||
c={c}
|
persistKey="siftlode.channelsTable"
|
||||||
userTags={userTags}
|
controlsPosition="top"
|
||||||
canWrite={canWrite}
|
controlsLeading={statusChips}
|
||||||
onUnsubscribe={async () => {
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
const ok = await confirm({
|
emptyText={t("channels.empty")}
|
||||||
title: t("channels.row.unsubscribeOnYoutube"),
|
/>
|
||||||
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
|
||||||
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
|
||||||
danger: true,
|
|
||||||
});
|
|
||||||
if (ok) unsubscribe.mutate(c.id);
|
|
||||||
}}
|
|
||||||
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
|
||||||
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
|
|
||||||
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
|
||||||
onDeep={() =>
|
|
||||||
patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
|
|
||||||
}
|
|
||||||
onToggleTag={(tagId) =>
|
|
||||||
c.tag_ids.includes(tagId)
|
|
||||||
? detach.mutate({ id: c.id, tagId })
|
|
||||||
: attach.mutate({ id: c.id, tagId })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -374,153 +480,180 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelRow({
|
function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) {
|
||||||
c,
|
const { t } = useTranslation();
|
||||||
userTags,
|
return (
|
||||||
canWrite,
|
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||||
onUnsubscribe,
|
<div className="inline-flex flex-col items-center cursor-help">
|
||||||
onView,
|
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
|
||||||
onPriority,
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
onHide,
|
</button>
|
||||||
onDeep,
|
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
||||||
onToggleTag,
|
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}>
|
||||||
}: {
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
c: ManagedChannel;
|
</button>
|
||||||
userTags: Tag[];
|
</div>
|
||||||
canWrite: boolean;
|
</Tooltip>
|
||||||
onUnsubscribe: () => void;
|
);
|
||||||
onView: () => void;
|
}
|
||||||
onPriority: (delta: number) => void;
|
|
||||||
onHide: () => void;
|
function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) {
|
||||||
onDeep: () => void;
|
|
||||||
onToggleTag: (tagId: number) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ytUrl = c.handle
|
const ytUrl = c.handle
|
||||||
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
|
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
|
||||||
: `https://www.youtube.com/channel/${c.id}`;
|
: `https://www.youtube.com/channel/${c.id}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
|
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||||
c.hidden ? "opacity-60" : ""
|
<button
|
||||||
}`}
|
onClick={onView}
|
||||||
>
|
onAuxClick={(e) => {
|
||||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
// Middle-click opens the channel's YouTube page in a new tab; left-click
|
||||||
<div className="flex flex-col items-center cursor-help">
|
// keeps opening the in-app channel detail.
|
||||||
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
|
if (e.button === 1) {
|
||||||
<ArrowUp className="w-3.5 h-3.5" />
|
e.preventDefault();
|
||||||
</button>
|
window.open(ytUrl, "_blank", "noopener,noreferrer");
|
||||||
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
}
|
||||||
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}>
|
}}
|
||||||
<ArrowDown className="w-3.5 h-3.5" />
|
onMouseDown={(e) => {
|
||||||
</button>
|
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
||||||
</div>
|
}}
|
||||||
</Tooltip>
|
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
||||||
|
|
||||||
<Avatar
|
|
||||||
src={c.thumbnail_url}
|
|
||||||
fallback={c.title ?? ""}
|
|
||||||
className="w-10 h-10 rounded-full shrink-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1 max-w-full">
|
|
||||||
<button
|
|
||||||
onClick={onView}
|
|
||||||
onAuxClick={(e) => {
|
|
||||||
// Middle-click opens the channel's YouTube page in a new tab; left-click
|
|
||||||
// keeps opening the in-app channel detail.
|
|
||||||
if (e.button === 1) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.open(ytUrl, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
|
||||||
}}
|
|
||||||
className="text-sm font-semibold truncate hover:text-accent text-left min-w-0"
|
|
||||||
>
|
|
||||||
{c.title ?? c.id}
|
|
||||||
</button>
|
|
||||||
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
|
||||||
<a
|
|
||||||
href={ytUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted hover:text-accent shrink-0"
|
|
||||||
aria-label={t("channels.row.openOnYouTube")}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
|
||||||
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
|
|
||||||
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
|
||||||
<SyncBadge
|
|
||||||
ok={c.recent_synced}
|
|
||||||
label={t("channels.row.recent")}
|
|
||||||
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
|
|
||||||
/>
|
|
||||||
{c.backfill_done ? (
|
|
||||||
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
|
|
||||||
) : c.deep_requested ? (
|
|
||||||
<Tooltip hint={t("channels.row.queuedRequestedHint")}>
|
|
||||||
<button
|
|
||||||
onClick={onDeep}
|
|
||||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent bg-accent text-accent-fg transition"
|
|
||||||
>
|
|
||||||
<History className="w-3 h-3" />
|
|
||||||
{t("channels.row.fullHistoryQueued")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
) : c.deep_in_queue ? (
|
|
||||||
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
|
|
||||||
<History className="w-3 h-3" />
|
|
||||||
{t("channels.row.fullHistoryComing")}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Tooltip hint={t("channels.row.getFullHistoryHint")}>
|
|
||||||
<button
|
|
||||||
onClick={onDeep}
|
|
||||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border bg-card border-border text-muted hover:border-accent transition"
|
|
||||||
>
|
|
||||||
<History className="w-3 h-3" />
|
|
||||||
{t("channels.row.getFullHistory")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{userTags.map((t) => {
|
|
||||||
const on = c.tag_ids.includes(t.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => onToggleTag(t.id)}
|
|
||||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
|
|
||||||
on
|
|
||||||
? "bg-accent text-accent-fg border-accent"
|
|
||||||
: "bg-card border-border text-muted hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
|
|
||||||
>
|
>
|
||||||
|
{c.title ?? c.id}
|
||||||
|
</button>
|
||||||
|
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
||||||
|
<a
|
||||||
|
href={ytUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted hover:text-accent shrink-0"
|
||||||
|
aria-label={t("channels.row.openOnYouTube")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status only — the backfill *action* lives in the Actions column. When both recent and
|
||||||
|
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
|
||||||
|
// present plus the missing/queued state.
|
||||||
|
function SyncCell({ c }: { c: ManagedChannel }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (c.recent_synced && c.backfill_done) {
|
||||||
|
return <SyncBadge ok label={t("channels.row.fullySynced")} hint={t("channels.row.fullySyncedHint")} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<SyncBadge
|
||||||
|
ok={c.recent_synced}
|
||||||
|
label={t("channels.row.recent")}
|
||||||
|
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
|
||||||
|
/>
|
||||||
|
{c.backfill_done ? (
|
||||||
|
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
|
||||||
|
) : c.deep_requested ? (
|
||||||
|
<SyncBadge ok={false} label={t("channels.row.fullHistoryQueued")} hint={t("channels.row.queuedRequestedHint")} />
|
||||||
|
) : c.deep_in_queue ? (
|
||||||
|
<SyncBadge ok={false} label={t("channels.row.fullHistoryComing")} hint={t("channels.row.queuedByOtherHint")} />
|
||||||
|
) : (
|
||||||
|
<SyncBadge ok={false} label={t("channels.row.full")} hint={t("channels.row.fullNotFetchedHint")} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagsCell({
|
||||||
|
c,
|
||||||
|
userTags,
|
||||||
|
onToggleTag,
|
||||||
|
}: {
|
||||||
|
c: ManagedChannel;
|
||||||
|
userTags: Tag[];
|
||||||
|
onToggleTag: (tagId: number) => void;
|
||||||
|
}) {
|
||||||
|
if (userTags.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{userTags.map((tg) => {
|
||||||
|
const on = c.tag_ids.includes(tg.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tg.id}
|
||||||
|
onClick={() => onToggleTag(tg.id)}
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
|
||||||
|
on
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tg.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionsCell({
|
||||||
|
c,
|
||||||
|
isAdmin,
|
||||||
|
canWrite,
|
||||||
|
onHide,
|
||||||
|
onDeep,
|
||||||
|
onReset,
|
||||||
|
onUnsubscribe,
|
||||||
|
}: {
|
||||||
|
c: ManagedChannel;
|
||||||
|
isAdmin: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
onDeep: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onUnsubscribe: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
|
||||||
|
// state — always actionable. Regular users get the per-user "request full history" opt-in,
|
||||||
|
// which only applies until the back-catalog is in (or already coming for everyone).
|
||||||
|
const done = c.backfill_done;
|
||||||
|
const requested = !done && c.deep_requested;
|
||||||
|
const optInActionable = !done && !c.deep_in_queue;
|
||||||
|
const backfillHint = isAdmin
|
||||||
|
? t("channels.row.resetHint")
|
||||||
|
: c.deep_in_queue
|
||||||
|
? t("channels.row.queuedByOtherHint")
|
||||||
|
: requested
|
||||||
|
? t("channels.row.queuedRequestedHint")
|
||||||
|
: done
|
||||||
|
? t("channels.row.fullHint")
|
||||||
|
: t("channels.row.backfillThisHint");
|
||||||
|
const enabled = isAdmin || optInActionable;
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Tooltip hint={backfillHint}>
|
||||||
|
<button
|
||||||
|
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
|
||||||
|
disabled={!enabled}
|
||||||
|
className={`shrink-0 transition ${
|
||||||
|
requested
|
||||||
|
? "text-accent"
|
||||||
|
: enabled
|
||||||
|
? "text-muted hover:text-fg"
|
||||||
|
: "text-muted/40 cursor-default"
|
||||||
|
}`}
|
||||||
|
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
|
||||||
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}>
|
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}>
|
||||||
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
421
frontend/src/components/DataTable.tsx
Normal file
421
frontend/src/components/DataTable.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
||||||
|
|
||||||
|
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||||
|
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||||
|
// the view. Columns are declared by the caller; the table is generic over the row type, so
|
||||||
|
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
|
||||||
|
// list built from the same column definitions.
|
||||||
|
|
||||||
|
export type ColumnFilter<T> =
|
||||||
|
| { kind: "text"; get: (row: T) => string }
|
||||||
|
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
|
||||||
|
| { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean };
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
align?: "left" | "right" | "center";
|
||||||
|
width?: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
sortValue?: (row: T) => string | number;
|
||||||
|
filter?: ColumnFilter<T>;
|
||||||
|
// Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard`
|
||||||
|
// omits the column; `cardLabel:false` shows the value without its header label.
|
||||||
|
cardPrimary?: boolean;
|
||||||
|
hideInCard?: boolean;
|
||||||
|
cardLabel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortState = { key: string; dir: "asc" | "desc" } | null;
|
||||||
|
type FilterMap = Record<string, string | string[]>;
|
||||||
|
interface Persisted {
|
||||||
|
sort: SortState;
|
||||||
|
filters: FilterMap;
|
||||||
|
page: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersist(key?: string): Persisted {
|
||||||
|
const empty: Persisted = { sort: null, filters: {}, page: 0 };
|
||||||
|
if (!key) return empty;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(key) || "{}");
|
||||||
|
return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size };
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(value: string | string[] | undefined): boolean {
|
||||||
|
return Array.isArray(value) ? value.length > 0 : !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T>({
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
rowKey,
|
||||||
|
pageSize = 10,
|
||||||
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
|
persistKey,
|
||||||
|
emptyText,
|
||||||
|
rowClassName,
|
||||||
|
controlsPosition = "bottom",
|
||||||
|
controlsLeading,
|
||||||
|
}: {
|
||||||
|
rows: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
pageSize?: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
persistKey?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
rowClassName?: (row: T) => string;
|
||||||
|
controlsPosition?: "top" | "bottom" | "both";
|
||||||
|
controlsLeading?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const initial = loadPersist(persistKey);
|
||||||
|
const [sort, setSort] = useState<SortState>(initial.sort);
|
||||||
|
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||||
|
const [page, setPage] = useState(initial.page);
|
||||||
|
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||||
|
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||||
|
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||||
|
const [pageInput, setPageInput] = useState("1");
|
||||||
|
const popRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
||||||
|
}, [persistKey, sort, filters, page, size]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openFilter) return;
|
||||||
|
function onDown(e: MouseEvent) {
|
||||||
|
if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null);
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpenFilter(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [openFilter]);
|
||||||
|
|
||||||
|
const filtered = rows.filter((row) =>
|
||||||
|
columns.every((col) => {
|
||||||
|
const f = col.filter;
|
||||||
|
if (!f) return true;
|
||||||
|
const val = filters[col.key];
|
||||||
|
if (!isActive(val)) return true;
|
||||||
|
if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase());
|
||||||
|
if (f.kind === "select") return f.test(row, String(val));
|
||||||
|
return f.test(row, val as string[]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined;
|
||||||
|
const sorted =
|
||||||
|
sort && sortCol?.sortValue
|
||||||
|
? [...filtered].sort((a, b) => {
|
||||||
|
const av = sortCol.sortValue!(a);
|
||||||
|
const bv = sortCol.sortValue!(b);
|
||||||
|
const cmp =
|
||||||
|
typeof av === "number" && typeof bv === "number"
|
||||||
|
? av - bv
|
||||||
|
: String(av).localeCompare(String(bv));
|
||||||
|
return sort.dir === "asc" ? cmp : -cmp;
|
||||||
|
})
|
||||||
|
: filtered;
|
||||||
|
|
||||||
|
// size <= 0 means "All" — one page with every row.
|
||||||
|
const allRows = size <= 0;
|
||||||
|
const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
||||||
|
|
||||||
|
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
||||||
|
function commitPageInput() {
|
||||||
|
const n = parseInt(pageInput, 10);
|
||||||
|
if (!isNaN(n)) setPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
||||||
|
else setPageInput(String(safePage + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(key: string) {
|
||||||
|
setSort((s) =>
|
||||||
|
!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null
|
||||||
|
);
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
function applyFilter(key: string, value: string | string[]) {
|
||||||
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = (a?: "left" | "right" | "center") =>
|
||||||
|
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
|
||||||
|
|
||||||
|
function FilterPopover({ col }: { col: Column<T> }) {
|
||||||
|
const f = col.filter!;
|
||||||
|
const val = filters[col.key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popRef}
|
||||||
|
className="glass-menu absolute left-0 top-full mt-1 z-30 w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{f.kind === "text" && (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={(val as string) ?? ""}
|
||||||
|
onChange={(e) => applyFilter(col.key, e.target.value)}
|
||||||
|
placeholder={col.header}
|
||||||
|
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{f.kind === "select" && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => applyFilter(col.key, "")}
|
||||||
|
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
!isActive(val) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("datatable.all")}
|
||||||
|
</button>
|
||||||
|
{f.options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => applyFilter(col.key, o.value)}
|
||||||
|
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
val === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{f.kind === "multi" && (
|
||||||
|
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
|
||||||
|
{f.options.length === 0 && (
|
||||||
|
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||||
|
)}
|
||||||
|
{f.options.map((o) => {
|
||||||
|
const arr = (val as string[]) ?? [];
|
||||||
|
const on = arr.includes(o.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() =>
|
||||||
|
applyFilter(
|
||||||
|
col.key,
|
||||||
|
on ? arr.filter((x) => x !== o.value) : [...arr, o.value]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
on ? "text-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||||
|
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <X className="w-2.5 h-2.5" />}
|
||||||
|
</span>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isActive(val) && (
|
||||||
|
<button
|
||||||
|
onClick={() => applyFilter(col.key, f.kind === "multi" ? [] : "")}
|
||||||
|
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("datatable.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls =
|
||||||
|
controlsLeading != null || sorted.length > 0 ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 my-3 text-sm flex-wrap">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">{controlsLeading}</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={safePage === 0}
|
||||||
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
{t("datatable.pager.prev")}
|
||||||
|
</button>
|
||||||
|
<span className="text-muted flex items-center gap-1.5">
|
||||||
|
{t("datatable.pager.pageLabel")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={(e) => setPageInput(e.target.value)}
|
||||||
|
onBlur={commitPageInput}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
||||||
|
aria-label={t("datatable.pager.pageLabel")}
|
||||||
|
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={safePage >= totalPages - 1}
|
||||||
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
{t("datatable.pager.next")}
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sorted.length > 0 && (
|
||||||
|
<label className="flex items-center gap-2 text-muted">
|
||||||
|
{t("datatable.rowsPerPage")}
|
||||||
|
<select
|
||||||
|
value={size}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSize(Number(e.target.value));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={0}>{t("datatable.all")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{(controlsPosition === "top" || controlsPosition === "both") && controls}
|
||||||
|
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted border-b border-border">
|
||||||
|
{columns.map((col) => {
|
||||||
|
const sorted_ = sort?.key === col.key;
|
||||||
|
const filterOn = isActive(filters[col.key]);
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
className={`relative font-medium px-2 py-2 ${align(col.align)}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => col.sortable && toggleSort(col.key)}
|
||||||
|
className={`inline-flex items-center gap-1 ${
|
||||||
|
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
{col.sortable && sorted_ && sort && (
|
||||||
|
sort.dir === "asc" ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{col.filter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
|
||||||
|
aria-label={t("datatable.filter")}
|
||||||
|
className={`transition ${
|
||||||
|
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListFilter className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{openFilter === col.key && col.filter && <FilterPopover col={col} />}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={rowKey(row)}
|
||||||
|
className={`border-b border-border/50 hover:bg-card/40 transition ${
|
||||||
|
rowClassName?.(row) ?? ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} style={col.width ? { width: col.width } : undefined} className={`px-2 py-2 align-middle ${align(col.align)}`}>
|
||||||
|
{col.render(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Narrow screens: a compact card per row — the primary column as the heading, the
|
||||||
|
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
|
||||||
|
<div className="md:hidden flex flex-col gap-2">
|
||||||
|
{paged.map((row) => (
|
||||||
|
<div
|
||||||
|
key={rowKey(row)}
|
||||||
|
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
||||||
|
>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.cardPrimary)
|
||||||
|
.map((col) => (
|
||||||
|
<div key={col.key} className="font-medium">
|
||||||
|
{col.render(row)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2 text-sm">
|
||||||
|
{columns
|
||||||
|
.filter((c) => !c.hideInCard && !c.cardPrimary)
|
||||||
|
.map((col) => (
|
||||||
|
<span key={col.key} className="inline-flex items-center gap-1">
|
||||||
|
{col.cardLabel !== false && (
|
||||||
|
<span className="text-muted text-xs">{col.header}</span>
|
||||||
|
)}
|
||||||
|
{col.render(row)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paged.length === 0 && (
|
||||||
|
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(controlsPosition === "bottom" || controlsPosition === "both") && controls}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
|
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
|
||||||
"filterPlaceholder": "Kanäle filtern…",
|
"filterPlaceholder": "Kanäle filtern…",
|
||||||
"syncSubscriptions": "Abos synchronisieren",
|
"syncSubscriptions": "Abos von YouTube lesen",
|
||||||
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
|
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
|
||||||
"backfillEverything": "Alles nachladen",
|
"backfillEverything": "Alles nachladen",
|
||||||
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
|
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
|
||||||
|
|
@ -19,6 +19,20 @@
|
||||||
},
|
},
|
||||||
"loading": "Kanäle werden geladen…",
|
"loading": "Kanäle werden geladen…",
|
||||||
"empty": "Keine Kanäle.",
|
"empty": "Keine Kanäle.",
|
||||||
|
"pager": {
|
||||||
|
"prev": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"page": "Seite {{page}} von {{total}}"
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"priority": "Prio",
|
||||||
|
"channel": "Kanal",
|
||||||
|
"stored": "Gespeichert",
|
||||||
|
"subs": "Abonnenten",
|
||||||
|
"sync": "Sync",
|
||||||
|
"tags": "Tags",
|
||||||
|
"actions": "Aktionen"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"channels": "Kanäle",
|
"channels": "Kanäle",
|
||||||
"channelsHint": "Kanäle, die du abonniert hast.",
|
"channelsHint": "Kanäle, die du abonniert hast.",
|
||||||
|
|
@ -45,6 +59,13 @@
|
||||||
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
|
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
|
||||||
"full": "vollständig",
|
"full": "vollständig",
|
||||||
"fullHint": "Vollständiger Verlauf geladen.",
|
"fullHint": "Vollständiger Verlauf geladen.",
|
||||||
|
"fullNotFetchedHint": "Das vollständige Archiv ist noch nicht geladen — nutze die Backfill-Aktion in der Spalte Aktionen.",
|
||||||
|
"fullySynced": "vollständig synchron",
|
||||||
|
"fullySyncedHint": "Aktuelle Uploads und das vollständige Archiv sind beide vorhanden.",
|
||||||
|
"backfillThis": "Diesen Kanal vollständig laden",
|
||||||
|
"backfillThisHint": "Das vollständige Archiv dieses Kanals laden (ältere Videos + komplette Suche). Erneut klicken, um die Anfrage zurückzuziehen.",
|
||||||
|
"resetBackfill": "Kanal zurücksetzen & neu laden",
|
||||||
|
"resetHint": "Admin: diesen Kanal von Grund auf neu laden (aktuelle + vollständiges Archiv), unabhängig vom aktuellen Sync-Status.",
|
||||||
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
|
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
|
||||||
"fullHistoryComing": "vollständiger Verlauf unterwegs",
|
"fullHistoryComing": "vollständiger Verlauf unterwegs",
|
||||||
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
|
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
|
||||||
|
|
@ -67,7 +88,10 @@
|
||||||
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
||||||
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
|
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
|
||||||
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
|
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
|
||||||
"connect": "Verbinden"
|
"connect": "Verbinden",
|
||||||
|
"resetDone": "Kanal zurückgesetzt — wird neu geladen",
|
||||||
|
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus."
|
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
||||||
|
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/de/datatable.json
Normal file
15
frontend/src/i18n/locales/de/datatable.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"filter": "Filtern",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"all": "Alle",
|
||||||
|
"rowsPerPage": "Zeilen pro Seite",
|
||||||
|
"noOptions": "Keine Optionen",
|
||||||
|
"empty": "Nichts anzuzeigen.",
|
||||||
|
"pager": {
|
||||||
|
"prev": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"page": "Seite {{page}} von {{total}}",
|
||||||
|
"pageLabel": "Seite",
|
||||||
|
"ofTotal": "von {{total}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
||||||
"filterPlaceholder": "Filter channels…",
|
"filterPlaceholder": "Filter channels…",
|
||||||
"syncSubscriptions": "Sync subscriptions",
|
"syncSubscriptions": "Read subscriptions from YouTube",
|
||||||
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
|
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
|
||||||
"backfillEverything": "Backfill everything",
|
"backfillEverything": "Backfill everything",
|
||||||
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
|
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
|
||||||
|
|
@ -19,6 +19,20 @@
|
||||||
},
|
},
|
||||||
"loading": "Loading channels…",
|
"loading": "Loading channels…",
|
||||||
"empty": "No channels.",
|
"empty": "No channels.",
|
||||||
|
"pager": {
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"page": "Page {{page}} of {{total}}"
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"priority": "Prio",
|
||||||
|
"channel": "Channel",
|
||||||
|
"stored": "Stored",
|
||||||
|
"subs": "Subscribers",
|
||||||
|
"sync": "Sync",
|
||||||
|
"tags": "Tags",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
"channelsHint": "Channels you're subscribed to.",
|
"channelsHint": "Channels you're subscribed to.",
|
||||||
|
|
@ -45,6 +59,13 @@
|
||||||
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
||||||
"full": "full",
|
"full": "full",
|
||||||
"fullHint": "Full history fetched.",
|
"fullHint": "Full history fetched.",
|
||||||
|
"fullNotFetchedHint": "Full back-catalog not fetched yet — use the backfill action in the Actions column.",
|
||||||
|
"fullySynced": "fully synced",
|
||||||
|
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
|
||||||
|
"backfillThis": "Backfill this channel",
|
||||||
|
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
|
||||||
|
"resetBackfill": "Reset & re-fetch this channel",
|
||||||
|
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
|
||||||
"fullHistoryQueued": "full history queued",
|
"fullHistoryQueued": "full history queued",
|
||||||
"fullHistoryComing": "full history coming",
|
"fullHistoryComing": "full history coming",
|
||||||
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
||||||
|
|
@ -67,7 +88,10 @@
|
||||||
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
||||||
"fullHistoryFailed": "Couldn't request full history",
|
"fullHistoryFailed": "Couldn't request full history",
|
||||||
"needYouTube": "Connect your YouTube account to do this.",
|
"needYouTube": "Connect your YouTube account to do this.",
|
||||||
"connect": "Connect"
|
"connect": "Connect",
|
||||||
|
"resetDone": "Channel reset — re-fetching now",
|
||||||
|
"resetFailed": "Couldn't reset this channel"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead."
|
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
||||||
|
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/en/datatable.json
Normal file
15
frontend/src/i18n/locales/en/datatable.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"filter": "Filter",
|
||||||
|
"clear": "Clear",
|
||||||
|
"all": "All",
|
||||||
|
"rowsPerPage": "Rows per page",
|
||||||
|
"noOptions": "No options",
|
||||||
|
"empty": "Nothing to show.",
|
||||||
|
"pager": {
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"page": "Page {{page}} of {{total}}",
|
||||||
|
"pageLabel": "Page",
|
||||||
|
"ofTotal": "of {{total}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
||||||
"filterPlaceholder": "Csatornák szűrése…",
|
"filterPlaceholder": "Csatornák szűrése…",
|
||||||
"syncSubscriptions": "Feliratkozások szinkronizálása",
|
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
|
||||||
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
||||||
"backfillEverything": "Minden letöltése",
|
"backfillEverything": "Minden letöltése",
|
||||||
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
|
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
|
||||||
|
|
@ -19,6 +19,20 @@
|
||||||
},
|
},
|
||||||
"loading": "Csatornák betöltése…",
|
"loading": "Csatornák betöltése…",
|
||||||
"empty": "Nincsenek csatornák.",
|
"empty": "Nincsenek csatornák.",
|
||||||
|
"pager": {
|
||||||
|
"prev": "Előző",
|
||||||
|
"next": "Következő",
|
||||||
|
"page": "{{page}}. oldal / {{total}}"
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"priority": "Prio",
|
||||||
|
"channel": "Csatorna",
|
||||||
|
"stored": "Tárolt",
|
||||||
|
"subs": "Feliratkozók",
|
||||||
|
"sync": "Szinkron",
|
||||||
|
"tags": "Címkék",
|
||||||
|
"actions": "Műveletek"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"channels": "Csatorna",
|
"channels": "Csatorna",
|
||||||
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
|
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
|
||||||
|
|
@ -45,6 +59,13 @@
|
||||||
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
|
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
|
||||||
"full": "teljes",
|
"full": "teljes",
|
||||||
"fullHint": "Teljes előzmény letöltve.",
|
"fullHint": "Teljes előzmény letöltve.",
|
||||||
|
"fullNotFetchedHint": "A teljes archívum még nincs letöltve — használd a Műveletek oszlop backfill gombját.",
|
||||||
|
"fullySynced": "teljesen szinkronban",
|
||||||
|
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
||||||
|
"backfillThis": "Csatorna teljes letöltése",
|
||||||
|
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
|
||||||
|
"resetBackfill": "Csatorna resetelése és újraletöltése",
|
||||||
|
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
|
||||||
"fullHistoryQueued": "teljes előzmény sorban",
|
"fullHistoryQueued": "teljes előzmény sorban",
|
||||||
"fullHistoryComing": "teljes előzmény úton",
|
"fullHistoryComing": "teljes előzmény úton",
|
||||||
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
||||||
|
|
@ -67,7 +88,10 @@
|
||||||
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
||||||
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
|
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
|
||||||
"connect": "Csatlakoztatás"
|
"connect": "Csatlakoztatás",
|
||||||
|
"resetDone": "Csatorna resetelve — újraletöltés folyamatban",
|
||||||
|
"resetFailed": "Nem sikerült resetelni a csatornát"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el."
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
||||||
|
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/hu/datatable.json
Normal file
15
frontend/src/i18n/locales/hu/datatable.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"filter": "Szűrés",
|
||||||
|
"clear": "Törlés",
|
||||||
|
"all": "Mind",
|
||||||
|
"rowsPerPage": "Sor/oldal",
|
||||||
|
"noOptions": "Nincs lehetőség",
|
||||||
|
"empty": "Nincs megjeleníthető elem.",
|
||||||
|
"pager": {
|
||||||
|
"prev": "Előző",
|
||||||
|
"next": "Következő",
|
||||||
|
"page": "{{page}}. oldal / {{total}}",
|
||||||
|
"pageLabel": "Oldal",
|
||||||
|
"ofTotal": "/ {{total}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -381,6 +381,8 @@ export const api = {
|
||||||
id: string,
|
id: string,
|
||||||
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
||||||
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
resetChannelBackfill: (id: string) =>
|
||||||
|
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
||||||
deepAll: (on = true) =>
|
deepAll: (on = true) =>
|
||||||
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||||
attachChannelTag: (id: string, tagId: number) =>
|
attachChannelTag: (id: string, tagId: number) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue