feat: align homepage with youtube layout

This commit is contained in:
Zhou Weidong committed 2026-11-11 11:51:18 +08:00
1 parent 01b1114909
commit e29fb4c859
5 files changed
+139 -10

No files matched your search

+1 -1
View File
@@ -5,5 +5,5 @@ import { applySeo } from '../lib/seo';
import { useEffect } from 'react';
const StoreContext = createContext(null);
export function StoreProvider({ children }) { const initial = new URLSearchParams(location.search).get('lang'); const [lang, setLangState] = useState(initial || 'id'); const [query, setQuery] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(false); const setLang = (next) => { setLangState(next); const url = new URL(location.href); url.searchParams.set('lang', next); history.replaceState(null, '', url); }; const locale = getLocale(lang); useEffect(() => applySeo(locale, location.pathname), [lang, locale]); const value = useMemo(() => ({ lang, locale, setLang, query, setQuery, sidebarOpen, setSidebarOpen }), [lang, locale, query, sidebarOpen]); return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; }
export function StoreProvider({ children }) { const initial = new URLSearchParams(location.search).get('lang'); const [lang, setLangState] = useState(initial || 'id'); const [query, setQuery] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 760); const setLang = (next) => { setLangState(next); const url = new URL(location.href); url.searchParams.set('lang', next); history.replaceState(null, '', url); }; const locale = getLocale(lang); useEffect(() => applySeo(locale, location.pathname), [lang, locale]); const value = useMemo(() => ({ lang, locale, setLang, query, setQuery, sidebarOpen, setSidebarOpen }), [lang, locale, query, sidebarOpen]); return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; }
export function useAppStore() { return useContext(StoreContext); }
+3 -3
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn
export const locales = {
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Cerita dalam satu tarikan napas', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Stories in one breath', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: '一口气看完的故事', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Cerita dalam satu tarikan napas', viewAll: 'Lihat semua →', explore: 'Jelajahi →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Stories in one breath', viewAll: 'View all →', explore: 'Explore →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: '一口气看完的故事', viewAll: '查看全部 →', explore: '探索 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
};
export function getLocale(code) { return locales[code] || locales.id; }
+55 -3
View File
@@ -1,12 +1,64 @@
// Author: fruit.com.cn
import { Bell, Bookmark, Compass, History, Home, Menu, Search, UserRound, X } from 'lucide-react';
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
export function AppShell() {
const { locale, lang, setLang, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore(); const navigate = useNavigate();
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
const navigate = useNavigate();
const [searchOpen, setSearchOpen] = useState(false);
const navItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions'], [Bookmark, locale.library, '/library'], [History, locale.history, '/history']];
const submitSearch = (event) => { event.preventDefault(); navigate(`/search?q=${encodeURIComponent(query)}`); };
return <><header className="topbar"><button className="mobile-only icon-button" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu"><Menu /></button><NavLink className="brand" to="/"><img src="/brand/ymb-logo-blue.png" alt="YMB" /></NavLink><form className="search-form" onSubmit={submitSearch}><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} /><button aria-label="Search"><Search size={19} /></button></form><div className="top-actions"><LanguageSwitcher /><Bell size={19} /><span className="profile-chip">N</span></div></header><aside className={`sidebar ${sidebarOpen ? 'open' : ''}`}><nav>{navItems.map(([Icon, label, to]) => <NavLink to={to} end={to === '/'} key={to}><Icon size={19} />{label}</NavLink>)}</nav><div className="nav-rule" /><small>GENRE POPULER</small>{locale.genres.slice(1).map((genre) => <button className="genre-link" key={genre} onClick={() => navigate(`/search?genre=${encodeURIComponent(genre)}`)}>{genre}</button>)}</aside><main className="content"><Outlet /></main><nav className="bottom-nav">{navItems.slice(0, 4).map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}</nav>{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close"><X /></button>}</>;
const submitSearch = (event) => {
event.preventDefault();
navigate(`/search?q=${encodeURIComponent(query)}`);
};
return (
<>
<header className="topbar">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={22} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
<button aria-label="Search" type="submit"><Search size={19} /></button>
</form>
<div className="top-actions">
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"><Search size={20} /></button>
<LanguageSwitcher />
<Bell size={19} aria-hidden="true" />
<span className="profile-chip" aria-label="Profile">N</span>
</div>
</header>
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<nav>
{navItems.map(([Icon, label, to]) => (
<NavLink to={to} end={to === '/'} key={to}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
</nav>
<div className="nav-rule" />
<small>GENRE POPULER</small>
{locale.genres.slice(1).map((genre) => (
<button className="genre-link" key={genre} onClick={() => navigate(`/search?genre=${encodeURIComponent(genre)}`)} type="button">
<span className="sidebar-label">{genre}</span>
</button>
))}
</aside>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet /></main>
<nav className="bottom-nav">
{navItems.slice(0, 4).map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}
</nav>
{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button"><X /></button>}
</>
);
}
+76 -3
View File
@@ -1,13 +1,86 @@
// Author: fruit.com.cn
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { videos } from '../data/catalog';
import { useAppStore } from '../app/store';
import { searchCatalog } from '../services/catalogService';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { normalizeDrama, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
export function HomePage() { const { locale } = useAppStore(); const [genre, setGenre] = useState(locale.genres[0]); const [toast, setToast] = useState(''); const api = useApi(() => appApi.homeFeed().then((payload) => uniqueDramas(payload?.sections?.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, locale.code)) || [])), [locale.code]); const remoteItems = api.data || []; const items = remoteItems.length ? remoteItems : searchCatalog('', genre, locale.genres.indexOf(genre)); const play = () => { setToast(locale.watch); setTimeout(() => setToast(''), 1400); }; return <><section className="hero-card"><div className="hero-copy"><small>YMB ORIGINAL</small><h1>{locale.hero}<br /><em>{locale.accent}</em></h1><p>{locale.description}</p><button className="primary-button" onClick={play}>▶ {locale.watch}</button></div><div className="hero-art"><img src="/assets/hero.jpg" alt="YMB drama" /></div></section><section><SectionHeading title={locale.picks} /><div className="chip-row">{locale.genres.map((item) => <button className={`chip ${genre === item ? 'active' : ''}`} key={item} onClick={() => setGenre(item)}>{item}</button>)}</div>{api.loading && <div className="api-note">{locale.loading}</div>}{api.error && <div className="api-note warning">{locale.apiFallback}</div>}<div className="video-grid">{items.map((video) => <MediaCard key={video.id} video={video} />)}</div></section><section><SectionHeading title={locale.shortTitle} action="Explore →" /><div className="shorts-row">{(remoteItems.length ? remoteItems.slice(0, 6) : videos.slice(1, 7)).map((video) => <MediaCard key={`short-${video.id}`} video={video} compact />)}</div></section>{toast && <div className="toast">{toast}</div>}</>;
const SHORTS_LIMIT = 6;
const ALL_GENRES_INDEX = 0;
function normalizeHomeFeed(payload, localeCode) {
const sections = payload?.sections || [];
const dramas = sections.flatMap((section) => section.dramas || []);
return uniqueDramas(dramas.map((item) => normalizeDrama(item, localeCode)));
}
function filterByGenre(items, genre, localeGenres) {
if (!genre || genre === localeGenres[ALL_GENRES_INDEX]) return items;
const filtered = items.filter((item) => item.genre === genre);
return filtered.length ? filtered : items;
}
export function HomePage() {
const { locale } = useAppStore();
const [genre, setGenre] = useState(locale.genres[ALL_GENRES_INDEX]);
const api = useApi(
() => appApi.homeFeed().then((payload) => normalizeHomeFeed(payload, locale.code)),
[locale.code],
);
const remoteItems = api.data || [];
const fallbackItems = searchCatalog('', genre, locale.genres.indexOf(genre));
const items = useMemo(
() => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, locale.genres),
[fallbackItems, genre, locale.genres, remoteItems],
);
const shortsItems = (remoteItems.length ? remoteItems : videos).slice(0, SHORTS_LIMIT);
useEffect(() => {
setGenre(locale.genres[ALL_GENRES_INDEX]);
}, [locale.code, locale.genres]);
return (
<>
<section className="youtube-home-header" aria-labelledby="home-feed-title">
<div>
<span className="eyebrow">YMB HOME</span>
<h1 id="home-feed-title">{locale.picks}</h1>
<p>{locale.description}</p>
</div>
<span className="feed-count">{items.length} {locale.videoCount}</span>
</section>
<section className="youtube-home-feed" aria-labelledby="recommended-title">
<SectionHeading title={locale.picks} action={locale.viewAll} />
<div className="chip-row" aria-label="Genre filters">
{locale.genres.map((item) => (
<button
className={`chip ${genre === item ? 'active' : ''}`}
key={item}
type="button"
onClick={() => setGenre(item)}
>
{item}
</button>
))}
</div>
{api.loading && <div className="api-note">{locale.loading}</div>}
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
<div className="video-grid" id="recommended-title">
{items.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
<SectionHeading title={locale.shortTitle} action={locale.explore} />
<div className="shorts-row" id="shorts-title">
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact />)}
</div>
</section>
</>
);
}