feat(channels): R14 zoom-fit table — fluid columns + column fogyókúra

- DataTable: opt-in `fixedLayout` (colgroup + table-layout:fixed) so the
  table fits its container at any zoom/text-size; only the channel manager
  opts in — playlists/discovery/Plex keep auto-sizing.
- Remove the N/S/L and Sync columns from the manager table. Fully-synced
  status becomes a ✓ charm on the Stored count; the sync filter moves to an
  `applySyncFilter` pre-filter still driven by the top chips.
- Channel column is now fluid: ChannelLink truncates with the full name on
  hover (title). Data columns get fixed widths so only Channel/About flex.
- N/S/L (normal/short/live) breakdown moves to the channel page: add the
  three counts to the channelDetail endpoint (one grouped query, same
  filters as the list) + the ChannelDetail type + a meta-line entry.
- Drop the now-dead sync/types i18n keys (en+hu).
This commit is contained in:
2026-08-15 05:28:59 +02:00
parent ea7bf593cb
commit 7ee0e9cd02
11 changed files with 114 additions and 129 deletions
+37 -97
View File
@@ -28,68 +28,37 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
};
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={`inline-flex items-center gap-1 text-3xs px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{ok && <Check className="w-3 h-3" />}
{label}
</span>
</Tooltip>
);
// 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;
}
// 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 }) {
// 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();
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-nowrap items-center gap-1 whitespace-nowrap">
<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")}
/>
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
{isFullySynced(c) && (
<Tooltip hint={t("channels.row.fullySyncedHint")}>
<Check
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
aria-label={t("channels.row.fullySynced")}
/>
</Tooltip>
)}
</div>
{c.stored_videos.toLocaleString()}
</span>
);
}
@@ -252,11 +221,11 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
// Without this a single long name blows the column ~289→429px and overflows the centered table.
// 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="max-w-[18rem]">
<div className="min-w-0">
<ChannelLink
id={c.id}
title={c.title}
@@ -271,6 +240,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
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} />,
},
{
@@ -278,17 +248,18 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
header: t("channels.cols.stored"),
align: "right",
nowrap: true,
width: "5.5rem",
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => (
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
),
// The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
render: (c) => <StoredCell c={c} />,
},
subsColumn<ManagedChannel>(t),
{ ...subsColumn<ManagedChannel>(t), width: "6rem" },
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
width: "6.5rem",
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>,
@@ -298,6 +269,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
header: t("channels.cols.length"),
align: "right",
nowrap: true,
width: "5rem",
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
@@ -306,38 +278,6 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
</span>
),
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
nowrap: true,
cardLabel: false,
filter: {
kind: "select",
options: [
{ value: "fully", label: t("channels.filters.fullySynced") },
{ value: "needsFull", label: t("channels.filters.needsFull") },
],
test: (c, v) =>
v === "fully"
? c.recent_synced && c.backfill_done
: v === "needsFull"
? !c.backfill_done
: true,
},
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),