style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
This commit is contained in:
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
@@ -15,7 +24,6 @@ import { api, type FeedFilters, type Me } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
|
||||
@@ -90,7 +98,7 @@ export default function ChannelPage({
|
||||
})
|
||||
.finally(() => setExploring(false));
|
||||
},
|
||||
[channelId, qc]
|
||||
[channelId, qc],
|
||||
);
|
||||
|
||||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
||||
@@ -167,14 +175,21 @@ export default function ChannelPage({
|
||||
const name = ch?.title ?? initialName ?? channelId;
|
||||
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
||||
const joined = ch?.published_at
|
||||
? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" })
|
||||
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
})
|
||||
: null;
|
||||
// Handle + stats on one compact meta line under the name (saves the separate stats row).
|
||||
const metaParts = [
|
||||
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
|
||||
ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
|
||||
ch?.subscriber_count != null
|
||||
? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })
|
||||
: null,
|
||||
ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
|
||||
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||
ch?.total_view_count != null
|
||||
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
|
||||
: null,
|
||||
joined ? t("channel.joined", { date: joined }) : null,
|
||||
].filter(Boolean) as string[];
|
||||
const tabClass = (active: boolean) =>
|
||||
@@ -188,211 +203,216 @@ export default function ChannelPage({
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||
<div className="relative px-4 sm:px-6 pt-3">
|
||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||||
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-2xl bg-surface"
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
>
|
||||
<img
|
||||
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
|
||||
// browser's negative cache (googleusercontent accepts an extra query param).
|
||||
key={bannerAttempt}
|
||||
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover object-center"
|
||||
onError={() => {
|
||||
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||
window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1));
|
||||
}
|
||||
}}
|
||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||
<div className="relative px-4 sm:px-6 pt-3">
|
||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||||
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-2xl bg-surface"
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
>
|
||||
<img
|
||||
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
|
||||
// browser's negative cache (googleusercontent accepts an extra query param).
|
||||
key={bannerAttempt}
|
||||
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover object-center"
|
||||
onError={() => {
|
||||
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||
window.setTimeout(
|
||||
() => setBannerAttempt((a) => a + 1),
|
||||
500 * (bannerAttempt + 1),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
|
||||
// overlapping avatar + the Back button have the same room and don't collide (a short bar
|
||||
// let the -mt-8 avatar ride up into the Back button).
|
||||
<div
|
||||
className="w-full rounded-2xl bg-surface"
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
|
||||
// overlapping avatar + the Back button have the same room and don't collide (a short bar
|
||||
// let the -mt-8 avatar ride up into the Back button).
|
||||
<div
|
||||
className="w-full rounded-2xl bg-surface"
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
data-testid="channel-back"
|
||||
onClick={closeChannel}
|
||||
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("channel.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6">
|
||||
{/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner
|
||||
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
|
||||
<div className="relative z-base flex items-end gap-4 -mt-8">
|
||||
<Avatar
|
||||
src={ch?.thumbnail_url ?? null}
|
||||
fallback={name}
|
||||
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">{name}</h1>
|
||||
{ch?.blocked ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||
{t("channel.blockedBadge")}
|
||||
</span>
|
||||
) : (
|
||||
ch?.explored &&
|
||||
!ch?.subscribed && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
{t("channel.exploringBadge")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||
{metaParts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="mr-1.5">·</span>}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
data-testid="channel-back"
|
||||
onClick={closeChannel}
|
||||
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("channel.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{exploring && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("channel.loadingVideos")}
|
||||
<div className="px-4 sm:px-6">
|
||||
{/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner
|
||||
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
|
||||
<div className="relative z-base flex items-end gap-4 -mt-8">
|
||||
<Avatar
|
||||
src={ch?.thumbnail_url ?? null}
|
||||
fallback={name}
|
||||
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">
|
||||
{name}
|
||||
</h1>
|
||||
{ch?.blocked ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||
{t("channel.blockedBadge")}
|
||||
</span>
|
||||
) : (
|
||||
ch?.explored &&
|
||||
!ch?.subscribed && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
{t("channel.exploringBadge")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||
{metaParts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="mr-1.5">·</span>}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
|
||||
{exploring && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("channel.loadingVideos")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
|
||||
corner: there are only ever two tabs, so there's room, and it keeps the things you click
|
||||
within a few pixels of each other instead of across the page. */}
|
||||
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
||||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||||
{t("channel.tabVideos")}
|
||||
</button>
|
||||
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
|
||||
{t("channel.tabAbout")}
|
||||
</button>
|
||||
<div className="ml-2 flex items-center gap-2 pb-1.5">
|
||||
<a
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("channels.row.openOnYouTube")}
|
||||
aria-label={t("channels.row.openOnYouTube")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
{ch?.subscribed && (
|
||||
<button
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||||
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
||||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||||
{t("channel.tabVideos")}
|
||||
</button>
|
||||
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
|
||||
{t("channel.tabAbout")}
|
||||
</button>
|
||||
<div className="ml-2 flex items-center gap-2 pb-1.5">
|
||||
<a
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("channels.row.openOnYouTube")}
|
||||
aria-label={t("channels.row.openOnYouTube")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
onClick={() => block.mutate()}
|
||||
disabled={block.isPending}
|
||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||
ch?.blocked
|
||||
? "border-danger text-danger"
|
||||
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||
}`}
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
{ch?.subscribed && (
|
||||
<button
|
||||
onClick={onUnsubscribe}
|
||||
disabled={unsubscribe.isPending}
|
||||
title={t("channel.unsubscribe")}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{t("channel.subscribedState")}
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
onClick={() => subscribe.mutate()}
|
||||
disabled={subscribe.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
onClick={() => block.mutate()}
|
||||
disabled={block.isPending}
|
||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||
ch?.blocked
|
||||
? "border-danger text-danger"
|
||||
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("channel.subscribe")}
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
<button
|
||||
onClick={onUnsubscribe}
|
||||
disabled={unsubscribe.isPending}
|
||||
title={t("channel.unsubscribe")}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{t("channel.subscribedState")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => subscribe.mutate()}
|
||||
disabled={subscribe.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("channel.subscribe")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
|
||||
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
|
||||
just under the tabs. */}
|
||||
<div ref={setChromeSlot} />
|
||||
<div ref={setChromeSlot} />
|
||||
</PageToolbar>
|
||||
|
||||
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
|
||||
only the grid (and About) render here, in the scroller. */}
|
||||
<PageToolbarSlot.Provider value={chromeSlot}>
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<button
|
||||
onClick={() => runExplore(nextToken)}
|
||||
disabled={exploring}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{exploring ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
{t("channel.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<button
|
||||
onClick={() => runExplore(nextToken)}
|
||||
disabled={exploring}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{exploring ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
{t("channel.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbarSlot.Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user