feat: connect YMB APIs and add swipe video player
This commit is contained in:
1 parent
eb3eb8dfc6
commit
6ba0ce1179
19 files changed
+113
-11
No files matched your search
Whitespace-only changes.
Binary file not shown.
|
After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
+2
-1
@@ -6,5 +6,6 @@ import { HomePage } from '../pages/HomePage';
|
|||||||
import { ListingPage } from '../pages/ListingPage';
|
import { ListingPage } from '../pages/ListingPage';
|
||||||
import { SearchPage } from '../pages/SearchPage';
|
import { SearchPage } from '../pages/SearchPage';
|
||||||
import { WatchPage } from '../pages/WatchPage';
|
import { WatchPage } from '../pages/WatchPage';
|
||||||
|
import { ShortsFeedPage } from '../pages/ShortsFeedPage';
|
||||||
|
|
||||||
export function App() { return <BrowserRouter><StoreProvider><Routes><Route element={<AppShell />}><Route path="/" element={<HomePage />} /><Route path="/shorts" element={<ListingPage type="shorts" />} /><Route path="/subscriptions" element={<ListingPage type="subscriptions" />} /><Route path="/library" element={<ListingPage type="library" />} /><Route path="/history" element={<ListingPage type="history" />} /><Route path="/search" element={<SearchPage />} /><Route path="/watch/:id" element={<WatchPage />} /></Route></Routes></StoreProvider></BrowserRouter>; }
|
export function App() { return <BrowserRouter><StoreProvider><Routes><Route element={<AppShell />}><Route path="/" element={<HomePage />} /><Route path="/shorts" element={<ShortsFeedPage />} /><Route path="/subscriptions" element={<ListingPage type="subscriptions" />} /><Route path="/library" element={<ListingPage type="library" />} /><Route path="/history" element={<ListingPage type="history" />} /><Route path="/search" element={<SearchPage />} /><Route path="/watch/:id" element={<WatchPage />} /></Route></Routes></StoreProvider></BrowserRouter>; }
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
import { createContext, useContext, useMemo, useState } from 'react';
|
import { createContext, useContext, useMemo, useState } from 'react';
|
||||||
import { getLocale } from '../i18n';
|
import { getLocale } from '../i18n';
|
||||||
|
import { applySeo } from '../lib/seo';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const StoreContext = createContext(null);
|
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 value = useMemo(() => ({ lang, locale: getLocale(lang), setLang, query, setQuery, sidebarOpen, setSidebarOpen }), [lang, 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(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 useAppStore() { return useContext(StoreContext); }
|
export function useAppStore() { return useContext(StoreContext); }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
import { Check, ChevronDown, Globe2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAppStore } from '../app/store';
|
||||||
|
|
||||||
|
const languages = [{ code: 'id', label: 'Bahasa Indonesia', short: 'ID', flag: '🇮🇩' }, { code: 'en', label: 'English', short: 'EN', flag: '🇺🇸' }, { code: 'zh', label: '简体中文', short: '中文', flag: '🇨🇳' }];
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { lang, setLang } = useAppStore(); const [open, setOpen] = useState(false); const current = languages.find((item) => item.code === lang) || languages[0];
|
||||||
|
return <div className="language-switcher"><button className="language-trigger" onClick={() => setOpen(!open)} aria-expanded={open}><Globe2 size={16} /><span>{current.flag}</span><b>{current.short}</b><ChevronDown size={14} /></button>{open && <><button className="language-backdrop" onClick={() => setOpen(false)} aria-label="Close language menu" /><div className="language-menu">{languages.map((item) => <button key={item.code} className={item.code === lang ? 'selected' : ''} onClick={() => { setLang(item.code); setOpen(false); }}><span>{item.flag}</span><span>{item.label}</span>{item.code === lang && <Check size={16} />}</button>)}</div></>}</div>;
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ import { Play } from 'lucide-react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export function MediaCard({ video, compact = false }) {
|
export function MediaCard({ video, compact = false }) {
|
||||||
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={`/watch/${video.id}`} className="media-thumb"><img src={`/assets/${video.image}`} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link><div className="media-meta"><span className="channel-avatar">{video.channel[0]}</span><div><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div></div></article>;
|
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
|
||||||
|
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={`/watch/${video.id}`} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link><div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div></div></article>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => { const video = ref.current; if (!video || !src) return undefined; const attempt = video.play(); attempt?.catch(() => {}); return () => video.pause(); }, [src]);
|
||||||
|
return <video ref={ref} className="video-player" src={src} poster={poster} controls playsInline autoPlay={autoPlay} muted={muted} onTimeUpdate={(event) => onProgress?.(event.currentTarget.currentTime)} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useApi(loader, deps = []) {
|
||||||
|
const [state, setState] = useState({ data: null, loading: true, error: '' });
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState({ data: null, loading: true, error: '' });
|
||||||
|
loader().then((data) => active && setState({ data, loading: false, error: '' })).catch((error) => active && setState({ data: null, loading: false, error: error.message || 'Request failed' }));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, deps);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
export const locales = {
|
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.' },
|
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.' },
|
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: '没有找到匹配的短剧。' }
|
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: '向上滑动发现下一个故事' }
|
||||||
};
|
};
|
||||||
export function getLocale(code) { return locales[code] || locales.id; }
|
export function getLocale(code) { return locales[code] || locales.id; }
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
import { Bell, Bookmark, Compass, History, Home, Menu, Search, UserRound, X } from 'lucide-react';
|
import { Bell, Bookmark, Compass, History, Home, Menu, Search, UserRound, X } from 'lucide-react';
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '../app/store';
|
import { useAppStore } from '../app/store';
|
||||||
|
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { locale, lang, setLang, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore(); const navigate = useNavigate();
|
const { locale, lang, setLang, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore(); const navigate = useNavigate();
|
||||||
const navItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions'], [Bookmark, locale.library, '/library'], [History, locale.history, '/history']];
|
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)}`); };
|
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="/"><span className="brand-symbol">▶</span>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"><select value={lang} onChange={(event) => setLang(event.target.value)} aria-label="Language"><option value="id">ID</option><option value="en">EN</option><option value="zh">中文</option></select><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>}</>;
|
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>}</>;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,9 @@ import { SectionHeading } from '../components/SectionHeading';
|
|||||||
import { videos } from '../data/catalog';
|
import { videos } from '../data/catalog';
|
||||||
import { useAppStore } from '../app/store';
|
import { useAppStore } from '../app/store';
|
||||||
import { searchCatalog } from '../services/catalogService';
|
import { searchCatalog } from '../services/catalogService';
|
||||||
|
import { appApi } from '../services/appApi';
|
||||||
|
import { normalizeDrama, normalizePage } 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 items = 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><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">{videos.slice(1, 7).map((video) => <MediaCard key={video.id} video={video} compact />)}</div></section>{toast && <div className="toast">{toast}</div>}</>;
|
export function HomePage() { const { locale } = useAppStore(); const [genre, setGenre] = useState(locale.genres[0]); const [toast, setToast] = useState(''); const api = useApi(() => appApi.dramas().then((payload) => normalizePage(payload).list.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">{videos.slice(1, 7).map((video) => <MediaCard key={video.id} video={video} compact />)}</div></section>{toast && <div className="toast">{toast}</div>}</>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Play } from 'lucide-react';
|
||||||
|
import { videos } from '../data/catalog';
|
||||||
|
import { appApi } from '../services/appApi';
|
||||||
|
import { normalizePage, normalizeDrama } from '../services/normalizers';
|
||||||
|
import { useApi } from '../hooks/useApi';
|
||||||
|
import { useAppStore } from '../app/store';
|
||||||
|
|
||||||
|
function FeedItem({ video }) { const ref = useRef(null); useEffect(() => { const element = ref.current; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) element.play().catch(() => {}); else element.pause(); }, { threshold: 0.7 }); observer.observe(element); return () => observer.disconnect(); }, []); return <article className="feed-item"><video ref={ref} src={video.preview || video.videoUrl} poster={video.image} muted loop playsInline controls={false} /><div className="feed-overlay"><span className="feed-badge">YMB SHORTS</span><h2>{video.title}</h2><p>{video.channel}</p><Link to={`/watch/${video.id}`} className="primary-button"><Play size={15} fill="currentColor" /> {video.watch || 'Watch drama'}</Link></div></article>; }
|
||||||
|
export function ShortsFeedPage() { const { locale } = useAppStore(); const api = useApi(() => appApi.dramas().then((payload) => normalizePage(payload).list.map((item) => normalizeDrama(item))), []); const items = api.data?.length ? api.data : videos.map((item) => ({ ...item, image: `/assets/${item.image}` })); return <section className="shorts-feed-page"><div className="feed-heading"><div><span>YMB SHORTS</span><h1>{locale.shortTitle}</h1></div><p>{locale.swipeHint || 'Swipe up to discover the next story'}</p></div>{api.loading && <div className="api-note">Loading real shorts…</div>}{items.map((video) => <FeedItem key={video.id} video={video} />)}</section>; }
|
||||||
@@ -3,5 +3,9 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { videos } from '../data/catalog';
|
import { videos } from '../data/catalog';
|
||||||
import { MediaCard } from '../components/MediaCard';
|
import { MediaCard } from '../components/MediaCard';
|
||||||
import { useAppStore } from '../app/store';
|
import { useAppStore } from '../app/store';
|
||||||
|
import { appApi } from '../services/appApi';
|
||||||
|
import { normalizeDrama } from '../services/normalizers';
|
||||||
|
import { useApi } from '../hooks/useApi';
|
||||||
|
import { VideoPlayer } from '../components/player/VideoPlayer';
|
||||||
|
|
||||||
export function WatchPage() { const { id } = useParams(); const { locale } = useAppStore(); const video = videos.find((item) => item.id === id) || videos[0]; return <div className="watch-layout"><div><div className="player"><img src={`/assets/${video.image}`} alt={video.title} /><button>▶</button></div><h1 className="watch-title">{video.title}</h1><p className="watch-subtitle">{video.channel} · {video.views}</p></div><aside className="up-next"><h2>{locale.picks}</h2>{videos.slice(0, 4).map((item) => <MediaCard key={item.id} video={item} />)}</aside></div>; }
|
export function WatchPage() { const { id } = useParams(); const { locale } = useAppStore(); const fallback = videos.find((item) => item.id === id) || videos[0]; const drama = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); const video = drama.data || fallback; const episode = video.episodes?.[0]; const playback = useApi(() => episode?.id ? appApi.episodePlay(episode.id) : Promise.resolve(null), [episode?.id]); const signedUrl = playback.data?.playUrl || playback.data?.url || playback.data?.signedUrl; return <div className="watch-layout"><div><div className="player">{signedUrl ? <VideoPlayer src={signedUrl} poster={video.image} /> : <><img src={video.image?.startsWith('/') ? video.image : `/assets/${video.image}`} alt={video.title} /><div className="player-state">{playback.loading ? locale.loading : locale.playbackUnavailable}</div></>}</div><h1 className="watch-title">{video.title}</h1><p className="watch-subtitle">{video.channel} · {video.views}</p>{playback.error && <div className="api-note warning">{playback.error}</div>}</div><aside className="up-next"><h2>{locale.picks}</h2>{videos.slice(0, 4).map((item) => <MediaCard key={item.id} video={item} />)}</aside></div>; }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
import { request } from './httpClient';
|
||||||
|
|
||||||
|
export const appApi = {
|
||||||
|
homeFeed: () => request('/app/home/feed'),
|
||||||
|
categories: () => request('/app/categories'),
|
||||||
|
dramas: (body = {}) => request('/app/dramas/page', { method: 'POST', body: JSON.stringify({ pageIndex: 1, pageSize: 20, categoryIds: [], classificationIds: [], tagIds: [], ...body }) }),
|
||||||
|
drama: (id) => request(`/app/dramas/${encodeURIComponent(id)}`),
|
||||||
|
episodePlay: (id) => request(`/app/episodes/${encodeURIComponent(id)}/play`),
|
||||||
|
progress: (body) => request('/app/watch/progress', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
|
||||||
|
const REQUEST_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
|
export async function request(path, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || (payload.code !== undefined && payload.code !== 0 && payload.code !== 200)) {
|
||||||
|
throw new Error(payload.message || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return payload.data === undefined ? payload : payload.data;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
const FALLBACK_IMAGE = '/assets/hero.jpg';
|
||||||
|
const getText = (value, lang = 'id') => (typeof value === 'string' ? value : value?.[lang] || value?.en || value?.id || value?.zh || '');
|
||||||
|
const getImage = (item) => item.coverUrl || item.cover || item.posterUrl || item.imageUrl || FALLBACK_IMAGE;
|
||||||
|
|
||||||
|
export function normalizeDrama(item, lang = 'id') {
|
||||||
|
const id = String(item.id ?? item.dramaId ?? item.code ?? '');
|
||||||
|
return { id, title: getText(item.locales || item.title, lang) || item.name || 'YMB Drama', channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals', genre: getText(item.categoryLocales || item.genre, lang), image: getImage(item), duration: item.duration || '', views: item.views || item.viewCount || '', episodes: item.episodes || item.episodeList || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePage(payload) {
|
||||||
|
if (Array.isArray(payload)) return { total: payload.length, list: payload };
|
||||||
|
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
/* Author: fruit.com.cn */
|
/* Author: fruit.com.cn */
|
||||||
@font-face{font-family:Jakarta;src:url('/assets/PlusJakartaSans-Regular.ttf')}@font-face{font-family:Jakarta;src:url('/assets/PlusJakartaSans-SemiBold.ttf');font-weight:600}@font-face{font-family:Outfit;src:url('/assets/Outfit-Bold.ttf');font-weight:700}:root{--bg:#0f0f0f;--surface:#181818;--line:#303030;--muted:#aaa;--accent:#ff4b3e;--sidebar:230px}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:#f1f1f1;font:14px Jakarta,Arial,sans-serif}a{color:inherit;text-decoration:none}button,input,select{font:inherit}.topbar{height:64px;position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:24px;padding:0 28px;background:#0f0f0ff2;border-bottom:1px solid #222;backdrop-filter:blur(15px)}.brand{display:flex;align-items:center;gap:8px;font:700 26px Outfit;min-width:150px}.brand-symbol{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--accent)}.search-form{display:flex;max-width:620px;flex:1;margin:auto}.search-form input{width:100%;height:40px;padding:0 16px;border:1px solid #3b3b3b;border-radius:22px 0 0 22px;background:#121212;color:#fff}.search-form button{width:54px;border:1px solid #3b3b3b;border-left:0;border-radius:0 22px 22px 0;background:#282828;color:#fff;display:grid;place-items:center}.top-actions{display:flex;align-items:center;gap:14px}.top-actions select{border:1px solid #363636;border-radius:16px;background:#1d1d1d;color:#ddd;padding:6px 9px;font-size:11px}.profile-chip{display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:#d45a68}.icon-button{border:0;background:transparent;color:#fff;display:grid;place-items:center}.sidebar{position:fixed;top:64px;bottom:0;width:var(--sidebar);padding:20px 12px;background:var(--bg);border-right:1px solid #222;z-index:4}.sidebar nav{display:grid;gap:4px}.sidebar nav a,.genre-link{display:flex;align-items:center;gap:16px;width:100%;padding:11px 14px;border:0;border-radius:10px;background:transparent;color:#ccc;text-align:left;cursor:pointer}.sidebar nav a:hover,.genre-link:hover{background:#272727;color:#fff}.sidebar small{display:block;padding:14px;color:#888;font-size:10px;letter-spacing:.12em}.nav-rule{height:1px;background:var(--line);margin:18px 10px}.content{max-width:1500px;padding:28px 34px 80px calc(var(--sidebar) + 34px);margin:auto}.hero-card{position:relative;min-height:350px;overflow:hidden;border-radius:18px;background:#27191a;margin-bottom:44px}.hero-copy{position:relative;z-index:1;width:52%;padding:54px}.hero-copy small,.section-heading span{color:#ff786f;font-size:11px;letter-spacing:.12em;font-weight:600}.hero-copy h1{margin:12px 0;font:700 56px/1 Outfit;letter-spacing:-.04em}.hero-copy h1 em{color:#ff786f;font-style:normal}.hero-copy p{max-width:390px;color:#ccc;line-height:1.7}.primary-button,.chip,.channel-card button{border:0;border-radius:22px;padding:11px 17px;cursor:pointer;font-weight:600}.primary-button{background:#fff;color:#111}.hero-art{position:absolute;inset:0 0 0 40%}.hero-art img{width:100%;height:100%;object-fit:cover}.hero-art:after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,#27191a,rgba(39,25,26,.72) 28%,transparent 80%)}section{margin-bottom:46px}.section-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:17px}.section-heading h2{margin:0;font:700 24px Outfit}.chip-row{display:flex;gap:8px;overflow:auto;margin-bottom:18px}.chip{background:#2b2b2b;color:#fff;white-space:nowrap}.chip.active,.chip:hover{background:#fff;color:#111}.video-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px 16px}.video-card{cursor:pointer;min-width:0}.thumb{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#222}.thumb img{width:100%;height:100%;object-fit:cover}.thumb b{position:absolute;right:7px;bottom:7px;padding:3px 6px;background:#000c;border-radius:4px;font-size:11px}.video-meta{display:flex;gap:9px;padding-top:9px}.channel-avatar{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:50%;font-weight:700}.video-meta strong,.channel-card strong{display:block;line-height:1.4}.video-meta small,.channel-card small{display:block;color:#999;font-size:11px;line-height:1.45;margin-top:4px}.shorts-row{display:grid;grid-template-columns:repeat(6,1fr);gap:14px}.short-card{position:relative;aspect-ratio:9/14;overflow:hidden;border-radius:11px;cursor:pointer}.short-card img{width:100%;height:100%;object-fit:cover}.short-card:after{content:'';position:absolute;inset:45% 0 0;background:linear-gradient(transparent,#000c)}.short-card strong{position:absolute;z-index:1;bottom:10px;left:9px;right:9px;font-size:12px}.channel-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.channel-card{display:flex;align-items:center;gap:10px;padding:14px;border:1px solid var(--line);border-radius:11px}.channel-card button{margin-left:auto;padding:8px 11px;background:#fff;color:#111}.bottom-nav,.mobile-only{display:none}.toast{position:fixedLine truncated
|
.brand img{width:92px;height:42px;object-fit:contain;object-position:left center}.language-switcher{position:relative}.language-trigger{display:flex;align-items:center;gap:6px;border:1px solid #363636;border-radius:18px;background:#1d1d1d;color:#eee;padding:7px 10px;cursor:pointer}.language-trigger b{font-size:11px}.language-menu{position:absolute;right:0;top:46px;width:190px;padding:7px;background:#222;border:1px solid #3b3b3b;border-radius:14px;box-shadow:0 18px 45px #0009;z-index:12}.language-menu button{display:flex;align-items:center;gap:10px;width:100%;padding:10px;border:0;border-radius:9px;background:transparent;color:#ddd;text-align:left;cursor:pointer}.language-menu button:hover,.language-menu button.selected{background:#343434;color:#fff}.language-menu button svg{margin-left:auto}.language-backdrop{position:fixed;inset:0;border:0;background:transparent;z-index:11}.language-menu{z-index:12}.api-note{padding:14px;border:1px dashed #3c3c3c;border-radius:10px;color:#aaa;margin-bottom:18px}.api-note.warning{color:#ffb0a8;border-color:#76433e}.player{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:14px;background:#070707}.player>img,.video-player{width:100%;height:100%;object-fit:contain}.player-state{position:absolute;inset:auto 0 18px;text-align:center;color:#ddd}.shorts-feed-page{max-width:820px;margin:0 auto}.feed-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:16px}.feed-heading span,.feed-badge{color:#ff786f;font-size:11px;letter-spacing:.12em;font-weight:600}.feed-heading h1{margin:5px 0 0;font:700 32px Outfit}.feed-heading p{color:#999}.feed-item{height:min(78vh,760px);position:relative;scroll-snap-align:start;overflow:hidden;border-radius:18px;background:#111;margin-bottom:18px}.feed-item video{width:100%;height:100%;object-fit:cover;background:#151515}.feed-overlay{position:absolute;left:26px;right:26px;bottom:26px;text-shadow:0 2px 14px #000}.feed-overlay h2{font:700 30px Outfit;margin:10px 0 4px}.feed-overlay p{color:#ddd}.feed-overlay .primary-button{display:inline-flex;align-items:center;gap:7px;text-shadow:none}.shorts-feed-page{scroll-snap-type:y proximity}.feed-badge{display:inline-block;padding:6px 9px;border-radius:999px;background:#0008}@media(max-width:760px){.brand img{width:74px;height:38px}.language-trigger{padding:7px 8px}.language-trigger svg,.language-trigger b{display:none}.top-actions{gap:9px}.feed-heading{display:block}.feed-heading p{margin-top:8px}.feed-item{height:calc(100dvh - 130px);margin:0 -14px 14px;border-radius:0}.feed-overlay{left:20px;right:20px;bottom:28px}.feed-overlay h2{font-size:24px}.shorts-feed-page{margin:0 -14px}.shorts-feed-page .feed-heading{padding:0 14px}}
|
||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({ plugins: [react()] });
|
export default defineConfig({ plugins: [react()], server: { proxy: { '/app': { target: 'http://127.0.0.1:8081', changeOrigin: true } } } });
|
||||||
Reference in new issue
Block a user