chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n

- Extract notifyYouTubeActionError (new lib/youtubeErrors.ts): the "403 → Connect
  your YouTube account, else fallback toast" logic was duplicated in Channels.tsx
  and ChannelDiscovery.tsx. (Separate file, not lib/notifications, to avoid the
  api ↔ notifications import cycle.)
- Extract format.formatCountOrDash(): the `n != null ? formatViews(n) : "—"` count
  cell was repeated across the subs/videos columns in Channels + ChannelDiscovery.
- Register channelsTable / channelDiscoveryTable in storage.ts LS instead of bare
  "siftlode.*" string literals.
- Delete 7 dead channels.json keys (filterPlaceholder, tags.newTag/createTag,
  row.stored/subs/getFullHistory/getFullHistoryHint) from en/hu/de — verified 0
  refs (createTag matches were the unrelated api.createTag method, not the key).

Behavior-neutral. tsc green, knip no new unused, localdev boots.
This commit is contained in:
npeter83 2026-07-11 18:11:23 +02:00
parent 8fb41383e6
commit ee601363c2
8 changed files with 54 additions and 71 deletions

View file

@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus } from "lucide-react"; import { UserPlus } from "lucide-react";
import { api, HttpError, type DiscoveredChannel } from "../lib/api"; import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { formatViews } from "../lib/format"; import { formatCountOrDash } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
@ -48,19 +49,8 @@ export default function ChannelDiscovery({
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name }, meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
}); });
}, },
onError: (err: unknown) => { onError: (err: unknown) =>
// A 403 means the user hasn't granted the write scope — offer to connect instead of notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
// a vague failure (mirrors the subscriptions tab).
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t("channels.discovery.subscribeFailed") });
}
},
}); });
// Subscribing changes the user's real YouTube account and spends a little quota — confirm // Subscribing changes the user's real YouTube account and spends a little quota — confirm
@ -96,9 +86,7 @@ export default function ChannelDiscovery({
sortable: true, sortable: true,
sortValue: (c) => c.subscriber_count ?? -1, sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => ( render: (c) => (
<span className="text-muted tabular-nums"> <span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
), ),
}, },
{ {
@ -109,9 +97,7 @@ export default function ChannelDiscovery({
sortable: true, sortable: true,
sortValue: (c) => c.video_count ?? -1, sortValue: (c) => c.video_count ?? -1,
render: (c) => ( render: (c) => (
<span className="text-muted tabular-nums"> <span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
{c.video_count != null ? formatViews(c.video_count) : "—"}
</span>
), ),
}, },
{ {
@ -174,7 +160,7 @@ export default function ChannelDiscovery({
rows={rows} rows={rows}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined} persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top" controlsPosition="top"
emptyText={t("channels.discovery.empty")} emptyText={t("channels.discovery.empty")}
/> />

View file

@ -16,10 +16,11 @@ import {
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, type ManagedChannel, type Tag } from "../lib/api";
import { accountKey } from "../lib/storage"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { formatCountOrDash, formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
@ -73,19 +74,6 @@ export default function Channels({
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
const notifyActionError = (err: unknown, fallbackKey: string) => {
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t(fallbackKey) });
}
};
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 });
@ -160,7 +148,7 @@ export default function Channels({
invalidate(); invalidate();
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
}); });
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
@ -169,7 +157,7 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
}); });
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
@ -178,7 +166,7 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
}); });
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
@ -190,7 +178,7 @@ export default function Channels({
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
}); });
}, },
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
}); });
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list // Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
@ -294,9 +282,7 @@ export default function Channels({
sortable: true, sortable: true,
sortValue: (c) => c.subscriber_count ?? -1, sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => ( render: (c) => (
<span className="text-muted tabular-nums"> <span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
), ),
}, },
{ {
@ -570,7 +556,7 @@ export default function Channels({
rows={visibleChannels} rows={visibleChannels}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey={accountKey("siftlode.channelsTable") ?? undefined} persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}

View file

@ -1,6 +1,5 @@
{ {
"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…",
"syncSubscriptions": "Abos von YouTube lesen", "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",
@ -35,9 +34,7 @@
"tags": { "tags": {
"yourTags": "Deine Tags", "yourTags": "Deine Tags",
"manage": "Tags verwalten", "manage": "Tags verwalten",
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)", "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)"
"newTag": "neuer Tag",
"createTag": "Tag erstellen"
}, },
"loading": "Kanäle werden geladen…", "loading": "Kanäle werden geladen…",
"empty": "Keine Kanäle.", "empty": "Keine Kanäle.",
@ -74,8 +71,6 @@
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)." "quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
}, },
"row": { "row": {
"stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten",
"openOnYouTube": "Auf YouTube öffnen", "openOnYouTube": "Auf YouTube öffnen",
"editTags": "Tags bearbeiten", "editTags": "Tags bearbeiten",
"filterFeedByTag": "Feed nach „{{name}}“ filtern", "filterFeedByTag": "Feed nach „{{name}}“ filtern",
@ -98,8 +93,6 @@
"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.",
"queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.", "queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.",
"getFullHistory": "vollständigen Verlauf laden",
"getFullHistoryHint": "Bisher nur neueste Uploads. Klicke, um den gesamten Katalog dieses Kanals anzufordern (ältere Videos + vollständige Suche).",
"hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.", "hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.",
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.", "hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
"unhide": "Einblenden", "unhide": "Einblenden",

View file

@ -1,6 +1,5 @@
{ {
"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…",
"syncSubscriptions": "Read subscriptions from YouTube", "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",
@ -35,9 +34,7 @@
"tags": { "tags": {
"yourTags": "Your tags", "yourTags": "Your tags",
"manage": "Manage tags", "manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)"
"newTag": "new tag",
"createTag": "Create tag"
}, },
"loading": "Loading channels…", "loading": "Loading channels…",
"empty": "No channels.", "empty": "No channels.",
@ -74,8 +71,6 @@
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)." "quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
}, },
"row": { "row": {
"stored": "{{formatted}} stored",
"subs": "{{formatted}} subs",
"openOnYouTube": "Open on YouTube", "openOnYouTube": "Open on YouTube",
"editTags": "Edit tags", "editTags": "Edit tags",
"filterFeedByTag": "Filter the feed by “{{name}}”", "filterFeedByTag": "Filter the feed by “{{name}}”",
@ -98,8 +93,6 @@
"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.",
"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.",
"getFullHistory": "get full history",
"getFullHistoryHint": "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).",
"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.",
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.", "hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"unhide": "Unhide", "unhide": "Unhide",

View file

@ -1,6 +1,5 @@
{ {
"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…",
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról", "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",
@ -35,9 +34,7 @@
"tags": { "tags": {
"yourTags": "Címkéid", "yourTags": "Címkéid",
"manage": "Címkék kezelése", "manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)"
"newTag": "új címke",
"createTag": "Címke létrehozása"
}, },
"loading": "Csatornák betöltése…", "loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.", "empty": "Nincsenek csatornák.",
@ -74,8 +71,6 @@
"quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)." "quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)."
}, },
"row": { "row": {
"stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó",
"openOnYouTube": "Megnyitás a YouTube-on", "openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése", "editTags": "Címkék szerkesztése",
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”", "filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
@ -98,8 +93,6 @@
"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.",
"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.",
"getFullHistory": "teljes előzmény lekérése",
"getFullHistoryHint": "Egyelőre csak a legutóbbi feltöltések. Kattints a csatorna teljes archívumának lekéréséhez (régebbi videók + teljes keresés).",
"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.",
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.", "hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"unhide": "Megjelenítés", "unhide": "Megjelenítés",

View file

@ -101,3 +101,8 @@ export function formatViews(n: number | null): string {
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
return String(n); return String(n);
} }
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—";
}

View file

@ -16,6 +16,8 @@ export const LS = {
perfMode: "siftlode.perfMode", perfMode: "siftlode.perfMode",
channelFilter: "siftlode.channelFilter", channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView", channelsView: "siftlode.channelsView",
channelsTable: "siftlode.channelsTable",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
settingsTab: "siftlode.settingsTab", settingsTab: "siftlode.settingsTab",
statsTab: "siftlode.statsTab", statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab", adminUsersTab: "siftlode.adminUsersTab",

View file

@ -0,0 +1,25 @@
import { HttpError } from "./api";
import { notify } from "./notifications";
import i18n from "../i18n";
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the
// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error.
export function notifyYouTubeActionError(
err: unknown,
fallbackMessage: string,
onConnect?: () => void,
): void {
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: i18n.t("channels.notify.needYouTube"),
action: onConnect
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined,
});
} else {
notify({ level: "error", message: fallbackMessage });
}
}