Shave the manager table's fixed column widths (fixed sum 720->632px) and convert priority/actions from px to rem so they scale with the text-size slider like their siblings: tags 12->9rem, actions 104px->5.5rem, lastUpload 6.5->6rem, stored 5.5->5rem, length 5->4.5rem, priority 56px->3.5rem. Gives the fluid Channel/About columns ~90px more room, so the table fits without a horizontal scrollbar down to ~700px containers instead of overflowing at the md/iPad-portrait band.
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
import type { ComponentProps } from "react";
|
|
import type { TFunction } from "i18next";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
|
import type { ManagedChannel } from "../lib/api";
|
|
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
|
|
import type { Column } from "./DataTable";
|
|
import Tooltip from "./Tooltip";
|
|
import { PriorityStepper } from "./PriorityStepper";
|
|
import ChannelLink from "./ChannelLink";
|
|
import AboutCell from "./AboutCell";
|
|
import { ChannelTagsControl } from "./ChannelTags";
|
|
|
|
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
|
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
|
* channel row shapes. */
|
|
export function subsColumn<T extends { subscriber_count: number | null }>(t: TFunction): Column<T> {
|
|
return {
|
|
key: "subs",
|
|
header: t("channels.cols.subs"),
|
|
align: "right",
|
|
nowrap: true,
|
|
sortable: true,
|
|
sortValue: (c) => c.subscriber_count ?? -1,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
|
|
),
|
|
};
|
|
}
|
|
|
|
// A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the
|
|
// predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven
|
|
// by the top sync-filter chips.
|
|
const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done;
|
|
|
|
/** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone (R14) — its status folded
|
|
* into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a
|
|
* column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */
|
|
export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] {
|
|
if (value === "fully") return rows.filter(isFullySynced);
|
|
if (value === "needsFull") return rows.filter((c) => !c.backfill_done);
|
|
return rows;
|
|
}
|
|
|
|
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
|
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
|
// describes. Full detail stays in the hover tooltip.
|
|
function StoredCell({ c }: { c: ManagedChannel }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
|
|
{isFullySynced(c) && (
|
|
<Tooltip hint={t("channels.row.fullySyncedHint")}>
|
|
<Check
|
|
role="img"
|
|
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
|
|
aria-label={t("channels.row.fullySynced")}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
{c.stored_videos.toLocaleString()}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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")}
|
|
>
|
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</Tooltip>
|
|
{canWrite && (
|
|
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
|
<button
|
|
onClick={onUnsubscribe}
|
|
className="text-muted hover:text-danger shrink-0"
|
|
aria-label={t("channels.row.unsubscribeOnYoutube")}
|
|
>
|
|
<UserMinus className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
|
|
* this factory just wires them into `Column<ManagedChannel>` render/filter/sort defs so the ~150-line
|
|
* column block lives next to its cells instead of inside the page component. */
|
|
export interface ChannelColumnDeps {
|
|
t: TFunction;
|
|
isAdmin: boolean;
|
|
canWrite: boolean;
|
|
prioOptions: { value: string; label: string }[];
|
|
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
|
|
bumpPriority: (id: string, delta: number) => void;
|
|
setPriority: (id: string, value: number) => void;
|
|
onHide: (c: ManagedChannel) => void;
|
|
onDeep: (c: ManagedChannel) => void;
|
|
onReset: (c: ManagedChannel) => void;
|
|
onUnsub: (c: ManagedChannel) => void;
|
|
onView: (c: ManagedChannel) => void;
|
|
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
|
createAndAttachTag: (channelId: string, name: string) => void;
|
|
onFilterByTag: (tagId: number, name: string) => void;
|
|
}
|
|
|
|
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
|
const {
|
|
t,
|
|
isAdmin,
|
|
canWrite,
|
|
prioOptions,
|
|
userTags,
|
|
bumpPriority,
|
|
setPriority,
|
|
onHide,
|
|
onDeep,
|
|
onReset,
|
|
onUnsub,
|
|
onView,
|
|
toggleTag,
|
|
createAndAttachTag,
|
|
onFilterByTag,
|
|
} = deps;
|
|
return [
|
|
{
|
|
key: "priority",
|
|
header: t("channels.cols.priority"),
|
|
align: "center",
|
|
width: "3.5rem",
|
|
sortable: true,
|
|
hideInCard: true,
|
|
sortValue: (c) => c.priority,
|
|
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
|
|
render: (c) => (
|
|
<PriorityStepper
|
|
priority={c.priority}
|
|
onPriority={(d) => bumpPriority(c.id, d)}
|
|
onSet={(v) => setPriority(c.id, v)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: t("channels.cols.actions"),
|
|
align: "left",
|
|
nowrap: true,
|
|
width: "5.5rem",
|
|
cardLabel: false,
|
|
render: (c) => (
|
|
<ActionsCell
|
|
c={c}
|
|
isAdmin={isAdmin}
|
|
canWrite={canWrite}
|
|
onHide={() => onHide(c)}
|
|
onDeep={() => onDeep(c)}
|
|
onReset={() => onReset(c)}
|
|
onUnsubscribe={() => onUnsub(c)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
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,
|
|
// A FLUID column (R14): no width cap. Under the manager table's fixed layout this cell takes a
|
|
// share of the leftover space and ChannelLink truncates the name to fit (full name on hover via
|
|
// title), so a long name can't push the table wider than its container at any zoom.
|
|
render: (c) => (
|
|
<div className="min-w-0">
|
|
<ChannelLink
|
|
id={c.id}
|
|
title={c.title}
|
|
handle={c.handle}
|
|
thumbnailUrl={c.thumbnail_url}
|
|
onView={() => onView(c)}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "about",
|
|
header: t("channels.cols.about"),
|
|
hideInCard: true,
|
|
// The other FLUID column — AboutCell already truncates to its cell.
|
|
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
|
},
|
|
{
|
|
key: "stored",
|
|
header: t("channels.cols.stored"),
|
|
align: "right",
|
|
nowrap: true,
|
|
width: "5rem",
|
|
sortable: true,
|
|
sortValue: (c) => c.stored_videos,
|
|
// The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
|
|
render: (c) => <StoredCell c={c} />,
|
|
},
|
|
{ ...subsColumn<ManagedChannel>(t), width: "6rem" },
|
|
{
|
|
key: "lastUpload",
|
|
header: t("channels.cols.lastUpload"),
|
|
nowrap: true,
|
|
width: "6rem",
|
|
sortable: true,
|
|
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
|
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
|
|
},
|
|
{
|
|
key: "length",
|
|
header: t("channels.cols.length"),
|
|
align: "right",
|
|
nowrap: true,
|
|
width: "4.5rem",
|
|
sortable: true,
|
|
sortValue: (c) => c.total_duration_seconds,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">
|
|
{formatTotalHours(c.total_duration_seconds)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "tags",
|
|
header: t("channels.cols.tags"),
|
|
cardLabel: false,
|
|
// Bounded width so the cell can't widen the column and reflow the whole table; the control
|
|
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
|
|
width: "9rem",
|
|
filter: {
|
|
kind: "multi",
|
|
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
|
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
|
},
|
|
render: (c) => (
|
|
<ChannelTagsControl
|
|
userTags={userTags}
|
|
attachedIds={c.tag_ids}
|
|
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
|
onCreate={(name) => createAndAttachTag(c.id, name)}
|
|
onChipClick={onFilterByTag}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
}
|