From 7ee0e9cd027f5fba3c6e34d4a2a03da7a08cd4ef Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 15 Aug 2026 05:28:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(channels):=20R14=20zoom-fit=20table=20?= =?UTF-8?q?=E2=80=94=20fluid=20columns=20+=20column=20fogy=C3=B3k=C3=BAra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- backend/app/routes/channels.py | 25 +++- frontend/src/components/ChannelLink.tsx | 6 +- frontend/src/components/ChannelPage.tsx | 9 ++ frontend/src/components/Channels.tsx | 25 ++-- frontend/src/components/DataTable.tsx | 19 ++- frontend/src/components/channelColumns.tsx | 134 ++++++--------------- frontend/src/i18n/locales/en/channel.json | 1 + frontend/src/i18n/locales/en/channels.json | 10 -- frontend/src/i18n/locales/hu/channel.json | 1 + frontend/src/i18n/locales/hu/channels.json | 10 -- frontend/src/lib/api/channels.ts | 3 + 11 files changed, 114 insertions(+), 129 deletions(-) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index f3626d55..5220cdfc 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -229,6 +229,9 @@ def _channel_detail_dict( explored: bool, blocked: bool, stored: int, + count_normal: int, + count_short: int, + count_live: int, deep_requested: bool, priority: int, tag_ids: list[int], @@ -253,6 +256,11 @@ def _channel_detail_dict( "explored": explored, "blocked": blocked, "stored_videos": stored, + # Per-type breakdown (Normal / Shorts / Live) — R14 moved it off the manager table onto the + # channel page. Same aggregation the manager list uses (count_normal = stored − shorts − live). + "count_normal": count_normal, + "count_short": count_short, + "count_live": count_live, # Deep-backfill state, so the channel page can offer "get full history" when the catalog # holds only the recent-backfill/RSS subset (video_count is YouTube's raw total). "deep_requested": deep_requested, @@ -304,7 +312,19 @@ def channel_detail( ) ).first() is not None blocked = _is_blocked(db, user, channel_id) - stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 + # Stored total + the Normal/Shorts/Live split in one grouped pass (same filters as the manager + # list), so the channel page can show the per-type breakdown R14 moved off the table. + n_all, n_short, n_live = db.execute( + select( + func.count(Video.id), + func.count(Video.id).filter(Video.is_short.is_(True)), + func.count(Video.id).filter(Video.live_status.in_(LIVE_OR_UPCOMING)), + ).where(Video.channel_id == channel_id) + ).one() + stored = int(n_all or 0) + count_short = int(n_short or 0) + count_live = int(n_live or 0) + count_normal = max(0, stored - count_short - count_live) tag_ids = list( db.scalars( select(ChannelTag.tag_id).where( @@ -318,6 +338,9 @@ def channel_detail( explored=explored, blocked=blocked, stored=int(stored), + count_normal=count_normal, + count_short=count_short, + count_live=count_live, deep_requested=bool(sub.deep_requested) if sub else False, priority=int(sub.priority) if sub else 0, tag_ids=tag_ids, diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx index 08b78dbc..2fc67126 100644 --- a/frontend/src/components/ChannelLink.tsx +++ b/frontend/src/components/ChannelLink.tsx @@ -41,12 +41,16 @@ export default function ChannelLink({ onMouseDown={(e) => { if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll }} + // title = the full name, so a truncated fluid-column name is still readable on hover. + title={name} className="text-sm font-medium truncate hover:text-accent text-left min-w-0" > {name} ) : ( - {name} + + {name} + )} 0 + ? t("channel.videoTypes", { + normal: ch.count_normal, + short: ch.count_short, + live: ch.count_live, + }) + : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index c7566b86..45cf51c5 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -7,7 +7,7 @@ import ExcludedChannelList from "./ExcludedChannelList"; import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; -import { buildChannelColumns } from "./channelColumns"; +import { buildChannelColumns, applySyncFilter } from "./channelColumns"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable from "./DataTable"; @@ -236,17 +236,23 @@ export default function Channels() { setSearch(""); }, [filtersResetToken]); - // The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`) - // shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden - // channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows). - // Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied - // by DataTable in table mode and by filterRows below in card mode, over the same `columns`. + // Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top + // chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a + // column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's + // applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards, + // still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the + // name search filters at this step; the remaining column funnels run in DataTable / filterRows below. const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden); const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden); const q = search.trim().toLowerCase(); const visibleChannels = channels.filter( (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q), ); + // Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows. + const syncedChannels = useMemo( + () => applySyncFilter(visibleChannels, filters.sync), + [visibleChannels, filters.sync], + ); const s = statusQuery.data; const onView = (c: ManagedChannel) => @@ -371,8 +377,8 @@ export default function Channels() { // Cards have no column headers, so apply the shared column filters (sync + any funnel set in table // mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort. const sortedChannels = isCards - ? sortRows(filterRows(visibleChannels, columns, filters), columns, sort) - : visibleChannels; + ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort) + : syncedChannels; const { paged: pagedCards, pagerProps, @@ -527,7 +533,7 @@ export default function Channels() { ) : layout === "table" ? ( c.id} persistKey={accountKey(LS.channelsTable) ?? undefined} @@ -539,6 +545,7 @@ export default function Channels() { onSortChange={setSort} filters={filters} onFiltersChange={setFilters} + fixedLayout emptyText={t("channels.empty")} /> ) : ( diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 3c781b9b..8173ce6e 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -60,6 +60,7 @@ export default function DataTable({ onSortChange, filters: filtersProp, onFiltersChange, + fixedLayout = false, }: { rows: T[]; columns: Column[]; @@ -96,6 +97,12 @@ export default function DataTable({ // default internal, persistKey-backed filters. filters?: FilterMap; onFiltersChange?: (next: FilterMap) => void; + // Opt into `table-layout: fixed` (default is `auto`). In fixed mode the table width == its + // container at every zoom/text-size — the columns with an explicit `width` keep it, and the rest + // (the fluid text columns) split what remains and TRUNCATE instead of pushing the table wider than + // the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are + // untouched; the channel manager turns it on so its row never scrolls horizontally out of view. + fixedLayout?: boolean; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); @@ -224,7 +231,17 @@ export default function DataTable({ {(controlsPosition === "top" || controlsPosition === "both") && topControls} {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
- +
+ {/* In fixed-layout mode a colgroup drives the column widths: those with an explicit + `width` keep it, and the unspecified (fluid) columns split the remainder — so the + table never exceeds its container and the fluid cells truncate instead. */} + {fixedLayout && ( + + {columns.map((col) => ( + + ))} + + )} (t: TFu }; } -function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { - return ( - - - {ok && } - {label} - - - ); +// 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 ( - - ); - } return ( -
- - {c.backfill_done ? ( - - ) : c.deep_requested ? ( - - ) : c.deep_in_queue ? ( - - ) : ( - + + {isFullySynced(c) && ( + + + )} -
+ {c.stored_videos.toLocaleString()} + ); } @@ -252,11 +221,11 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column (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) => ( -
+
, }, { @@ -278,17 +248,18 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.stored_videos, - render: (c) => ( - {c.stored_videos.toLocaleString()} - ), + // The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in). + render: (c) => , }, - subsColumn(t), + { ...subsColumn(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) => {relativeTime(c.last_video_at) || "—"}, @@ -298,6 +269,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.total_duration_seconds, render: (c) => ( @@ -306,38 +278,6 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column ), }, - { - key: "types", - header: t("channels.cols.types"), - nowrap: true, - render: (c) => ( - - - {c.count_normal} / {c.count_short} / {c.count_live} - - - ), - }, - { - 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) => , - }, { key: "tags", header: t("channels.cols.tags"), diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json index d7d7ec28..b6bab16a 100644 --- a/frontend/src/i18n/locales/en/channel.json +++ b/frontend/src/i18n/locales/en/channel.json @@ -12,6 +12,7 @@ "videoCount_other": "{{count}} videos", "videoCountStored": "{{stored}} / {{total}} videos", "totalViews": "{{formatted}} views", + "videoTypes": "{{normal}} normal / {{short}} shorts / {{live}} live", "joined": "Joined {{date}}", "loadingVideos": "Loading this channel's videos…", "tabVideos": "Videos", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 57cdcfda..9eb7826e 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -68,9 +68,6 @@ "subs": "Subscribers", "lastUpload": "Last upload", "length": "Length", - "types": "N/S/L", - "typesHint": "Videos by type: Normal / Shorts / Live", - "sync": "Sync", "tags": "Tags", "actions": "Actions" }, @@ -97,20 +94,13 @@ "raisePriority": "Raise priority", "lowerPriority": "Lower priority", "editPriority": "Click to type a priority (negatives allowed)", - "recent": "recent", - "recentSyncedHint": "Recent uploads synced.", - "recentNotSyncedHint": "Recent uploads not fetched yet.", - "full": "full", "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": "queued", - "fullHistoryComing": "coming", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", "hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.", diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json index 77463ac2..ddaee097 100644 --- a/frontend/src/i18n/locales/hu/channel.json +++ b/frontend/src/i18n/locales/hu/channel.json @@ -12,6 +12,7 @@ "videoCount_other": "{{count}} videó", "videoCountStored": "{{stored}} / {{total}} videó", "totalViews": "{{formatted}} megtekintés", + "videoTypes": "{{normal}} normál / {{short}} short / {{live}} élő", "joined": "Csatlakozott: {{date}}", "loadingVideos": "A csatorna videóinak betöltése…", "tabVideos": "Videók", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 29df7265..6b392cfc 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -68,9 +68,6 @@ "subs": "Feliratkozók", "lastUpload": "Utolsó feltöltés", "length": "Hossz", - "types": "N/S/L", - "typesHint": "Videók típusonként: Normál / Short / Live", - "sync": "Szinkron", "tags": "Címkék", "actions": "Műveletek" }, @@ -97,20 +94,13 @@ "raisePriority": "Prioritás növelése", "lowerPriority": "Prioritás csökkentése", "editPriority": "Kattints a prioritás beírásához (negatív is lehet)", - "recent": "legutóbbi", - "recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.", - "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.", - "full": "teljes", "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": "sorban", - "fullHistoryComing": "ú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.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", "hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.", diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index b8417f85..f9cd4605 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -27,6 +27,9 @@ export interface ChannelDetail { explored: boolean; blocked: boolean; stored_videos: number; + count_normal: number; + count_short: number; + count_live: number; deep_requested: boolean; priority: number; backfill_done: boolean;