feat(channels): dedicated channel page + ephemeral explore UI
Frontend for the channel-explore feature: - ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests recent uploads on first visit (background, with a loading note) + "Load more from YouTube" to page deeper; skipped for demo / already-subscribed channels. - App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays. - ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter → onOpenChannel); the in-card "only this channel" filter button is dropped (the page subsumes it). PlayerModal channel-name wiring follows in the next commit. - api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types. - i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group, channels_explore quota label.
This commit is contained in:
parent
bc4c362423
commit
cc1e670202
18 changed files with 744 additions and 120 deletions
|
|
@ -28,6 +28,7 @@ import Header from "./components/Header";
|
|||
import NavSidebar from "./components/NavSidebar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import Feed from "./components/Feed";
|
||||
import ChannelPage from "./components/ChannelPage";
|
||||
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
|
||||
import Playlists from "./components/Playlists";
|
||||
import Stats from "./components/Stats";
|
||||
|
|
@ -140,6 +141,22 @@ export default function App() {
|
|||
if (window.history.state?._yt) window.history.back();
|
||||
else setYtSearch(null);
|
||||
}, []);
|
||||
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
|
||||
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
|
||||
// before the detail loads), so Back closes the channel page (after any player opened over it)
|
||||
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
|
||||
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(null);
|
||||
const openChannel = useCallback((id: string, name?: string) => {
|
||||
setChannelView({ id, name });
|
||||
const st = window.history.state || {};
|
||||
if (st._chan)
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
||||
}, []);
|
||||
const closeChannel = useCallback(() => {
|
||||
if (window.history.state?._chan) window.history.back();
|
||||
else setChannelView(null);
|
||||
}, []);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
||||
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
|
||||
|
|
@ -247,7 +264,8 @@ export default function App() {
|
|||
useEffect(() => {
|
||||
// Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch
|
||||
// begins null), so the first Back doesn't resurrect a search we're no longer showing.
|
||||
const { _yt: _staleYt, ...rest } = window.history.state || {};
|
||||
const { _yt: _staleYt, _chan: _staleChan, _chanName: _staleChanName, ...rest } =
|
||||
window.history.state || {};
|
||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
||||
function onPop(e: PopStateEvent) {
|
||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||
|
|
@ -273,6 +291,9 @@ export default function App() {
|
|||
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
||||
// clears it to match the entry we landed on (and a player popped first leaves it intact).
|
||||
setYtSearch((e.state?._yt as string) ?? null);
|
||||
// The channel page rides in history.state._chan the same way.
|
||||
const chan = e.state?._chan as string | undefined;
|
||||
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
|
|
@ -518,6 +539,18 @@ export default function App() {
|
|||
language={i18n.language as LangCode}
|
||||
/>
|
||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<ChannelPage
|
||||
key={channelView.id}
|
||||
channelId={channelView.id}
|
||||
initialName={channelView.name}
|
||||
me={meQuery.data!}
|
||||
view={view}
|
||||
onBack={closeChannel}
|
||||
onOpenChannel={openChannel}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Header
|
||||
me={meQuery.data!}
|
||||
filters={filters}
|
||||
|
|
@ -556,10 +589,7 @@ export default function App() {
|
|||
setView={setChannelsView}
|
||||
filtersResetToken={channelsFilterReset}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
onViewChannel={(id, name) => {
|
||||
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
||||
setPage("feed");
|
||||
}}
|
||||
onViewChannel={(id, name) => openChannel(id, name)}
|
||||
onFilterByTag={(tagId, name) => {
|
||||
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
|
||||
setPage("feed");
|
||||
|
|
@ -599,10 +629,13 @@ export default function App() {
|
|||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
onOpenChannel={openChannel}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
||||
Anchored inside the content column so they clear the sidebar automatically
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue