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:
@@ -229,6 +229,9 @@ def _channel_detail_dict(
|
|||||||
explored: bool,
|
explored: bool,
|
||||||
blocked: bool,
|
blocked: bool,
|
||||||
stored: int,
|
stored: int,
|
||||||
|
count_normal: int,
|
||||||
|
count_short: int,
|
||||||
|
count_live: int,
|
||||||
deep_requested: bool,
|
deep_requested: bool,
|
||||||
priority: int,
|
priority: int,
|
||||||
tag_ids: list[int],
|
tag_ids: list[int],
|
||||||
@@ -253,6 +256,11 @@ def _channel_detail_dict(
|
|||||||
"explored": explored,
|
"explored": explored,
|
||||||
"blocked": blocked,
|
"blocked": blocked,
|
||||||
"stored_videos": stored,
|
"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
|
# 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).
|
# holds only the recent-backfill/RSS subset (video_count is YouTube's raw total).
|
||||||
"deep_requested": deep_requested,
|
"deep_requested": deep_requested,
|
||||||
@@ -304,7 +312,19 @@ def channel_detail(
|
|||||||
)
|
)
|
||||||
).first() is not None
|
).first() is not None
|
||||||
blocked = _is_blocked(db, user, channel_id)
|
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(
|
tag_ids = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(ChannelTag.tag_id).where(
|
select(ChannelTag.tag_id).where(
|
||||||
@@ -318,6 +338,9 @@ def channel_detail(
|
|||||||
explored=explored,
|
explored=explored,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
stored=int(stored),
|
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,
|
deep_requested=bool(sub.deep_requested) if sub else False,
|
||||||
priority=int(sub.priority) if sub else 0,
|
priority=int(sub.priority) if sub else 0,
|
||||||
tag_ids=tag_ids,
|
tag_ids=tag_ids,
|
||||||
|
|||||||
@@ -41,12 +41,16 @@ export default function ChannelLink({
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
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"
|
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium truncate min-w-0">{name}</span>
|
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -264,6 +264,15 @@ export default function ChannelPage({
|
|||||||
ch?.total_view_count != null
|
ch?.total_view_count != null
|
||||||
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
|
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
|
||||||
: null,
|
: null,
|
||||||
|
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful
|
||||||
|
// once we actually hold some of the channel's videos.
|
||||||
|
ch && ch.stored_videos > 0
|
||||||
|
? t("channel.videoTypes", {
|
||||||
|
normal: ch.count_normal,
|
||||||
|
short: ch.count_short,
|
||||||
|
live: ch.count_live,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ExcludedChannelList from "./ExcludedChannelList";
|
|||||||
import { api, type ManagedChannel } from "../lib/api";
|
import { api, type ManagedChannel } from "../lib/api";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { buildChannelColumns } from "./channelColumns";
|
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable from "./DataTable";
|
import DataTable from "./DataTable";
|
||||||
@@ -236,17 +236,23 @@ export default function Channels() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
}, [filtersResetToken]);
|
}, [filtersResetToken]);
|
||||||
|
|
||||||
// The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`)
|
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
|
||||||
// shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden
|
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
|
||||||
// channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows).
|
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
|
||||||
// Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied
|
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
|
||||||
// by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
|
// 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 hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||||
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const visibleChannels = channels.filter(
|
const visibleChannels = channels.filter(
|
||||||
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
(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 s = statusQuery.data;
|
||||||
|
|
||||||
const onView = (c: ManagedChannel) =>
|
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
|
// 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.
|
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||||
const sortedChannels = isCards
|
const sortedChannels = isCards
|
||||||
? sortRows(filterRows(visibleChannels, columns, filters), columns, sort)
|
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort)
|
||||||
: visibleChannels;
|
: syncedChannels;
|
||||||
const {
|
const {
|
||||||
paged: pagedCards,
|
paged: pagedCards,
|
||||||
pagerProps,
|
pagerProps,
|
||||||
@@ -527,7 +533,7 @@ export default function Channels() {
|
|||||||
<LoadingState label={t("channels.loading")} />
|
<LoadingState label={t("channels.loading")} />
|
||||||
) : layout === "table" ? (
|
) : layout === "table" ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={visibleChannels}
|
rows={syncedChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||||
@@ -539,6 +545,7 @@ export default function Channels() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
|
fixedLayout
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function DataTable<T>({
|
|||||||
onSortChange,
|
onSortChange,
|
||||||
filters: filtersProp,
|
filters: filtersProp,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
|
fixedLayout = false,
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
@@ -96,6 +97,12 @@ export default function DataTable<T>({
|
|||||||
// default internal, persistKey-backed filters.
|
// default internal, persistKey-backed filters.
|
||||||
filters?: FilterMap;
|
filters?: FilterMap;
|
||||||
onFiltersChange?: (next: FilterMap) => void;
|
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 { t } = useTranslation();
|
||||||
const initial = loadPersist(persistKey);
|
const initial = loadPersist(persistKey);
|
||||||
@@ -224,7 +231,17 @@ export default function DataTable<T>({
|
|||||||
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
|
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
|
||||||
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className={`w-full border-collapse text-sm ${fixedLayout ? "table-fixed" : ""}`}>
|
||||||
|
{/* 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 && (
|
||||||
|
<colgroup>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<col key={col.key} style={col.width ? { width: col.width } : undefined} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
)}
|
||||||
<ColumnHead
|
<ColumnHead
|
||||||
columns={columns}
|
columns={columns}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
|
|||||||
@@ -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 }) {
|
// A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the
|
||||||
return (
|
// predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven
|
||||||
<Tooltip hint={hint ?? ""}>
|
// by the top sync-filter chips.
|
||||||
<span
|
const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done;
|
||||||
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"
|
/** 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). */
|
||||||
{ok && <Check className="w-3 h-3" />}
|
export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] {
|
||||||
{label}
|
if (value === "fully") return rows.filter(isFullySynced);
|
||||||
</span>
|
if (value === "needsFull") return rows.filter((c) => !c.backfill_done);
|
||||||
</Tooltip>
|
return rows;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status only — the backfill *action* lives in the Actions column. When both recent and
|
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
||||||
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
|
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||||
// present plus the missing/queued state.
|
// describes. Full detail stays in the hover tooltip.
|
||||||
function SyncCell({ c }: { c: ManagedChannel }) {
|
function StoredCell({ c }: { c: ManagedChannel }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (c.recent_synced && c.backfill_done) {
|
|
||||||
return (
|
|
||||||
<SyncBadge
|
|
||||||
ok
|
|
||||||
label={t("channels.row.fullySynced")}
|
|
||||||
hint={t("channels.row.fullySyncedHint")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
|
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
|
||||||
<SyncBadge
|
{isFullySynced(c) && (
|
||||||
ok={c.recent_synced}
|
<Tooltip hint={t("channels.row.fullySyncedHint")}>
|
||||||
label={t("channels.row.recent")}
|
<Check
|
||||||
hint={
|
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
|
||||||
c.recent_synced
|
aria-label={t("channels.row.fullySynced")}
|
||||||
? t("channels.row.recentSyncedHint")
|
/>
|
||||||
: t("channels.row.recentNotSyncedHint")
|
</Tooltip>
|
||||||
}
|
|
||||||
/>
|
|
||||||
{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>
|
{c.stored_videos.toLocaleString()}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +221,11 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
|
// A FLUID column (R14): no width cap. Under the manager table's fixed layout this cell takes a
|
||||||
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
|
// share of the leftover space and ChannelLink truncates the name to fit (full name on hover via
|
||||||
// Without this a single long name blows the column ~289→429px and overflows the centered table.
|
// title), so a long name can't push the table wider than its container at any zoom.
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<div className="max-w-[18rem]">
|
<div className="min-w-0">
|
||||||
<ChannelLink
|
<ChannelLink
|
||||||
id={c.id}
|
id={c.id}
|
||||||
title={c.title}
|
title={c.title}
|
||||||
@@ -271,6 +240,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
key: "about",
|
key: "about",
|
||||||
header: t("channels.cols.about"),
|
header: t("channels.cols.about"),
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
|
// The other FLUID column — AboutCell already truncates to its cell.
|
||||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
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"),
|
header: t("channels.cols.stored"),
|
||||||
align: "right",
|
align: "right",
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
|
width: "5.5rem",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.stored_videos,
|
sortValue: (c) => c.stored_videos,
|
||||||
render: (c) => (
|
// The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
|
||||||
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
|
render: (c) => <StoredCell c={c} />,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
subsColumn<ManagedChannel>(t),
|
{ ...subsColumn<ManagedChannel>(t), width: "6rem" },
|
||||||
{
|
{
|
||||||
key: "lastUpload",
|
key: "lastUpload",
|
||||||
header: t("channels.cols.lastUpload"),
|
header: t("channels.cols.lastUpload"),
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
|
width: "6.5rem",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
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>,
|
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"),
|
header: t("channels.cols.length"),
|
||||||
align: "right",
|
align: "right",
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
|
width: "5rem",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.total_duration_seconds,
|
sortValue: (c) => c.total_duration_seconds,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
@@ -306,38 +278,6 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
</span>
|
</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",
|
key: "tags",
|
||||||
header: t("channels.cols.tags"),
|
header: t("channels.cols.tags"),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"videoCount_other": "{{count}} videos",
|
"videoCount_other": "{{count}} videos",
|
||||||
"videoCountStored": "{{stored}} / {{total}} videos",
|
"videoCountStored": "{{stored}} / {{total}} videos",
|
||||||
"totalViews": "{{formatted}} views",
|
"totalViews": "{{formatted}} views",
|
||||||
|
"videoTypes": "{{normal}} normal / {{short}} shorts / {{live}} live",
|
||||||
"joined": "Joined {{date}}",
|
"joined": "Joined {{date}}",
|
||||||
"loadingVideos": "Loading this channel's videos…",
|
"loadingVideos": "Loading this channel's videos…",
|
||||||
"tabVideos": "Videos",
|
"tabVideos": "Videos",
|
||||||
|
|||||||
@@ -68,9 +68,6 @@
|
|||||||
"subs": "Subscribers",
|
"subs": "Subscribers",
|
||||||
"lastUpload": "Last upload",
|
"lastUpload": "Last upload",
|
||||||
"length": "Length",
|
"length": "Length",
|
||||||
"types": "N/S/L",
|
|
||||||
"typesHint": "Videos by type: Normal / Shorts / Live",
|
|
||||||
"sync": "Sync",
|
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
@@ -97,20 +94,13 @@
|
|||||||
"raisePriority": "Raise priority",
|
"raisePriority": "Raise priority",
|
||||||
"lowerPriority": "Lower priority",
|
"lowerPriority": "Lower priority",
|
||||||
"editPriority": "Click to type a priority (negatives allowed)",
|
"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.",
|
"fullHint": "Full history fetched.",
|
||||||
"fullNotFetchedHint": "Full back-catalog not fetched yet — use the backfill action in the Actions column.",
|
|
||||||
"fullySynced": "fully synced",
|
"fullySynced": "fully synced",
|
||||||
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
|
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
|
||||||
"backfillThis": "Backfill this channel",
|
"backfillThis": "Backfill this channel",
|
||||||
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
|
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
|
||||||
"resetBackfill": "Reset & re-fetch this channel",
|
"resetBackfill": "Reset & re-fetch this channel",
|
||||||
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
|
"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.",
|
"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.",
|
"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.",
|
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"videoCount_other": "{{count}} videó",
|
"videoCount_other": "{{count}} videó",
|
||||||
"videoCountStored": "{{stored}} / {{total}} videó",
|
"videoCountStored": "{{stored}} / {{total}} videó",
|
||||||
"totalViews": "{{formatted}} megtekintés",
|
"totalViews": "{{formatted}} megtekintés",
|
||||||
|
"videoTypes": "{{normal}} normál / {{short}} short / {{live}} élő",
|
||||||
"joined": "Csatlakozott: {{date}}",
|
"joined": "Csatlakozott: {{date}}",
|
||||||
"loadingVideos": "A csatorna videóinak betöltése…",
|
"loadingVideos": "A csatorna videóinak betöltése…",
|
||||||
"tabVideos": "Videók",
|
"tabVideos": "Videók",
|
||||||
|
|||||||
@@ -68,9 +68,6 @@
|
|||||||
"subs": "Feliratkozók",
|
"subs": "Feliratkozók",
|
||||||
"lastUpload": "Utolsó feltöltés",
|
"lastUpload": "Utolsó feltöltés",
|
||||||
"length": "Hossz",
|
"length": "Hossz",
|
||||||
"types": "N/S/L",
|
|
||||||
"typesHint": "Videók típusonként: Normál / Short / Live",
|
|
||||||
"sync": "Szinkron",
|
|
||||||
"tags": "Címkék",
|
"tags": "Címkék",
|
||||||
"actions": "Műveletek"
|
"actions": "Műveletek"
|
||||||
},
|
},
|
||||||
@@ -97,20 +94,13 @@
|
|||||||
"raisePriority": "Prioritás növelése",
|
"raisePriority": "Prioritás növelése",
|
||||||
"lowerPriority": "Prioritás csökkentése",
|
"lowerPriority": "Prioritás csökkentése",
|
||||||
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
|
"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.",
|
"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",
|
"fullySynced": "teljesen szinkronban",
|
||||||
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
|
||||||
"backfillThis": "Csatorna teljes letöltése",
|
"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.",
|
"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",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface ChannelDetail {
|
|||||||
explored: boolean;
|
explored: boolean;
|
||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
stored_videos: number;
|
stored_videos: number;
|
||||||
|
count_normal: number;
|
||||||
|
count_short: number;
|
||||||
|
count_live: number;
|
||||||
deep_requested: boolean;
|
deep_requested: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
backfill_done: boolean;
|
backfill_done: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user