refactor: structure YMB as enterprise React app
This commit is contained in:
1 parent
e575ebd948
commit
eb3eb8dfc6
18 files changed
+182
-20
No files matched your search
@@ -4,6 +4,29 @@
|
||||
|
||||
YMB 公开站点 React + Vite 项目,参考 YouTube 的内容发现信息架构,支持桌面端与移动端。
|
||||
|
||||
## 企业级目录
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/ # 路由与全局 Store
|
||||
├── layouts/ # AppShell、顶部栏、侧栏、移动端导航
|
||||
├── pages/ # Home、Shorts、Subscriptions、Library、History、Search、Watch
|
||||
├── components/ # MediaCard、SectionHeading 等复用组件
|
||||
├── features/ # 后续业务域扩展入口
|
||||
├── services/ # 目录检索与接口边界
|
||||
├── data/ # 演示目录数据
|
||||
├── i18n/ # ID / EN / zh-CN 语言包
|
||||
└── lib/seo.js # 页面 SEO 元数据入口
|
||||
```
|
||||
|
||||
## YouTube 对照的信息架构
|
||||
|
||||
- 顶部 AppBar:品牌、全局搜索、语言、通知、用户入口。
|
||||
- 桌面侧栏:Home、Shorts、Subscriptions、Library、History、Liked videos。
|
||||
- 首页:Hero 推荐、Topic chips、视频网格、Shorts 横滑、频道推荐。
|
||||
- 观看页:16:9 播放器、标题/频道信息、Up next 推荐队列。
|
||||
- 移动端:抽屉侧栏、底部四项导航、两列视频卡与横滑 Shorts。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {"dev":"vite","build":"vite build","preview":"vite preview"},
|
||||
"dependencies": {"@vitejs/plugin-react":"latest","vite":"latest","react":"latest","react-dom":"latest","lucide-react":"latest"},
|
||||
"dependencies": {"@vitejs/plugin-react":"latest","vite":"latest","react":"latest","react-dom":"latest","react-router-dom":"latest","lucide-react":"latest"},
|
||||
"devDependencies": {}
|
||||
}
|
||||
Generated
+45
@@ -20,6 +20,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: latest
|
||||
version: 19.3.1(react@19.3.1)
|
||||
react-router-dom:
|
||||
specifier: latest
|
||||
version: 7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
vite:
|
||||
specifier: latest
|
||||
version: 8.3.7
|
||||
@@ -138,6 +141,10 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -256,6 +263,23 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.3.1
|
||||
|
||||
react-router-dom@7.18.5:
|
||||
resolution: {integrity: sha512-9HcfDMU6JJ/fp6gJZYGwNE6RknVGS4kBjH1HkNw959mCJi70TVwdipQWv2FEol9uGpi+SZTFtVpJPtEQfayeOw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.18.5:
|
||||
resolution: {integrity: sha512-dTgvcXDOrLPrlLHrcZAqWveiK1hMSqN+rGAJzegP8HtmNkfRxOoshGWBNX3ZJ0cZjOHkRdlUJtaIhZMLLyGyPw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react@19.3.1:
|
||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -268,6 +292,9 @@ packages:
|
||||
scheduler@0.28.0:
|
||||
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||
|
||||
set-cookie-parser@2.7.3:
|
||||
resolution: {integrity: sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==}
|
||||
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -375,6 +402,8 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.1.0
|
||||
vite: 8.3.7
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
@@ -454,6 +483,20 @@ snapshots:
|
||||
react: 19.3.1
|
||||
scheduler: 0.28.0
|
||||
|
||||
react-router-dom@7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
react-router: 7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
|
||||
react-router@7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.3.1
|
||||
set-cookie-parser: 2.7.3
|
||||
optionalDependencies:
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
|
||||
react@19.3.1: {}
|
||||
|
||||
rolldown@1.2.18:
|
||||
@@ -479,6 +522,8 @@ snapshots:
|
||||
|
||||
scheduler@0.28.0: {}
|
||||
|
||||
set-cookie-parser@2.7.3: {}
|
||||
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { StoreProvider } from './store';
|
||||
import { AppShell } from '../layouts/AppShell';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { ListingPage } from '../pages/ListingPage';
|
||||
import { SearchPage } from '../pages/SearchPage';
|
||||
import { WatchPage } from '../pages/WatchPage';
|
||||
|
||||
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>; }
|
||||
@@ -0,0 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
import { getLocale } from '../i18n';
|
||||
|
||||
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 useAppStore() { return useContext(StoreContext); }
|
||||
@@ -0,0 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Play } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Author: fruit.com.cn
|
||||
export function SectionHeading({ title, action = 'Lihat semua →' }) { return <div className="section-heading"><h2>{title}</h2><button>{action}</button></div>; }
|
||||
@@ -0,0 +1,18 @@
|
||||
// Author: fruit.com.cn
|
||||
export const videos = [
|
||||
{ id: 'wedding', title: 'Pernikahan yang Tak Terduga', channel: 'YMB Originals', genre: 'Romansa', image: 'wedding.jpg', duration: '12:48', views: '1,2 jt tayangan' },
|
||||
{ id: 'secret', title: 'Rahasia di Balik Senyuman', channel: 'Cerita Senja', genre: 'Rahasia', image: 'secret.jpg', duration: '09:32', views: '842 rb tayangan' },
|
||||
{ id: 'heir', title: 'Pewaris yang Kembali', channel: 'Drama Pilihan', genre: 'Keluarga', image: 'heir.jpg', duration: '15:20', views: '2,1 jt tayangan' },
|
||||
{ id: 'second-chance', title: 'Kesempatan Kedua untuk Kita', channel: 'YMB Originals', genre: 'Kisah kedua', image: 'second-chance.jpg', duration: '11:04', views: '763 rb tayangan' },
|
||||
{ id: 'revenge', title: 'Cinta Setelah Balas Dendam', channel: 'Cerita Senja', genre: 'Balas dendam', image: 'revenge.jpg', duration: '13:40', views: '1,8 jt tayangan' },
|
||||
{ id: 'friends', title: 'Sahabat yang Selalu Ada', channel: 'Drama Pilihan', genre: 'Keluarga', image: 'friends.jpg', duration: '08:16', views: '541 rb tayangan' },
|
||||
{ id: 'city-secret', title: 'Kota Kecil, Rahasia Besar', channel: 'YMB Originals', genre: 'Rahasia', image: 'hero.jpg', duration: '10:12', views: '426 rb tayangan' },
|
||||
{ id: 'promise', title: 'Satu Janji Terakhir', channel: 'Drama Pilihan', genre: 'Romansa', image: 'romance.jpg', duration: '07:55', views: '320 rb tayangan' }
|
||||
];
|
||||
export const shorts = videos.slice(1, 7).map((video) => ({ ...video, title: `${video.title} · Shorts` }));
|
||||
export const channels = [
|
||||
{ name: 'YMB Originals', followers: '2,4 jt pengikut', color: '#ca4d43' },
|
||||
{ name: 'Cerita Senja', followers: '986 rb pengikut', color: '#5c4aa1' },
|
||||
{ name: 'Drama Pilihan', followers: '742 rb pengikut', color: '#2e7d71' },
|
||||
{ name: 'Kisah Hari Ini', followers: '428 rb pengikut', color: '#a06a39' }
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Author: fruit.com.cn */
|
||||
.sidebar nav a.active{background:#272727;color:#fff}.media-card{min-width:0}.media-thumb{display:block;position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#222}.media-thumb img{width:100%;height:100%;object-fit:cover;transition:transform .25s}.media-thumb:hover img{transform:scale(1.04)}.media-thumb b{position:absolute;right:7px;bottom:7px;padding:3px 6px;background:#000c;border-radius:4px;font-size:11px}.thumb-play{position:absolute;inset:0;display:grid;place-items:center;opacity:0;background:#0007;transition:opacity .2s}.media-thumb:hover .thumb-play{opacity:1}.media-meta{display:flex;gap:9px;padding-top:9px}.media-title{display:block;color:#fff;font-weight:600;line-height:1.4}.media-meta small{display:block;color:#999;font-size:11px;line-height:1.45;margin-top:4px}.media-card.compact{flex:0 0 150px}.media-card.compact .media-meta{display:none}.shorts-row .media-thumb{aspect-ratio:9/14;border-radius:11px}.shorts-row .media-thumb b,.shorts-row .thumb-play{display:none}.watch-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:28px}.player{position:relative;aspect-ratio:16/9;background:#050505;border-radius:12px;overflow:hidden}.player img{width:100%;height:100%;object-fit:cover;filter:brightness(.7)}.player button{position:absolute;inset:50% auto auto 50%;transform:translate(-50%,-50%);width:64px;height:64px;border:0;border-radius:50%;background:#fff;color:#111;font-size:24px;cursor:pointer}.watch-title{margin:18px 0 8px;font:700 28px Outfit}.watch-subtitle{color:#999}.up-next h2{margin:0 0 16px;font:700 21px Outfit}.up-next .media-card{margin-bottom:16px}.up-next .channel-avatar{display:none}.up-next .media-title{font-size:13px}.up-next .media-meta small{font-size:10px}.empty-state{padding:40px;color:#999}@media(max-width:900px){.watch-layout{grid-template-columns:1fr}.up-next{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.up-next h2{grid-column:1/-1}}@media(max-width:760px){.media-card.compact{flex:0 0 130px}.watch-title{font-size:22px}.up-next{display:block}.up-next .media-card{margin-bottom:14px}.up-next .media-thumb{width:42%;display:inline-block;vertical-align:top}.up-next .media-meta{display:inline-flex;width:54%;vertical-align:top;padding-left:8px;padding-top:0}}
|
||||
@@ -0,0 +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.' },
|
||||
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.' },
|
||||
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: '没有找到匹配的短剧。' }
|
||||
};
|
||||
export function getLocale(code) { return locales[code] || locales.id; }
|
||||
@@ -0,0 +1,11 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Bell, Bookmark, Compass, History, Home, Menu, Search, UserRound, X } from 'lucide-react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
|
||||
export function AppShell() {
|
||||
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 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>}</>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
export function applySeo(locale, route = '') {
|
||||
document.title = locale.title;
|
||||
document.documentElement.lang = locale.htmlLang;
|
||||
document.querySelector('meta[name="description"]')?.setAttribute('content', locale.description);
|
||||
document.querySelector('link[rel="canonical"]')?.setAttribute('href', `https://ymb.example.com${route || '/'}`);
|
||||
}
|
||||
+2
-19
@@ -1,24 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Bell, Bookmark, Compass, History, Home, Menu, Play, Search, UserRound, X } from 'lucide-react';
|
||||
import { App } from './app/App';
|
||||
import './styles.css';
|
||||
import './enterprise.css';
|
||||
|
||||
const ASSET = '/assets/';
|
||||
const copy = {
|
||||
id: { title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shorts: 'Cerita dalam satu tarikan napas', channels: 'Ikuti cerita favoritmu', follow: 'Ikuti', following: 'Mengikuti', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia'] },
|
||||
en: { title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shorts: 'Stories in one breath', channels: 'Follow your favorite stories', follow: 'Follow', following: 'Following', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets'] },
|
||||
zh: { title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shorts: '一口气看完的故事', channels: '关注你喜欢的故事', follow: '关注', following: '已关注', genres: ['全部', '爱情', '复仇', '家庭', '秘密'] }
|
||||
};
|
||||
const videos = [['Pernikahan yang Tak Terduga','YMB Originals','Romansa','wedding.jpg','12:48'],['Rahasia di Balik Senyuman','Cerita Senja','Rahasia','secret.jpg','09:32'],['Pewaris yang Kembali','Drama Pilihan','Keluarga','heir.jpg','15:20'],['Kesempatan Kedua untuk Kita','YMB Originals','Kisah kedua','second-chance.jpg','11:04'],['Cinta Setelah Balas Dendam','Cerita Senja','Balas dendam','revenge.jpg','13:40'],['Sahabat yang Selalu Ada','Drama Pilihan','Keluarga','friends.jpg','08:16'],['Kota Kecil, Rahasia Besar','YMB Originals','Rahasia','hero.jpg','10:12'],['Satu Janji Terakhir','Drama Pilihan','Romansa','romance.jpg','07:55']];
|
||||
const shorts = [['Dia kembali setelah 5 tahun','second-chance.jpg'],['Ketika rahasia terbongkar','secret.jpg'],['Satu pesan yang mengubah semuanya','romance.jpg'],['Jangan percaya senyumnya','revenge.jpg'],['Pewaris tanpa nama','heir.jpg'],['Hari pernikahan itu','wedding.jpg']];
|
||||
const channels = [['YMB Originals','2,4 jt pengikut','Y','#ca4d43'],['Cerita Senja','986 rb pengikut','C','#5c4aa1'],['Drama Pilihan','742 rb pengikut','D','#2e7d71'],['Kisah Hari Ini','428 rb pengikut','K','#a06a39']];
|
||||
function useLanguage() { const initial = new URLSearchParams(location.search).get('lang'); return useState(copy[initial] ? initial : 'id'); }
|
||||
function App() {
|
||||
const [lang, setLang] = useLanguage(); const text = copy[lang]; const [genre, setGenre] = useState(text.genres[0]); const [query, setQuery] = useState(''); const [menu, setMenu] = useState(false); const [toast, setToast] = useState('');
|
||||
const filtered = useMemo(() => videos.filter((item) => (!query || item.join(' ').toLowerCase().includes(query.toLowerCase())) && (genre === text.genres[0] || item[2] === genre)), [genre, query, text]);
|
||||
const notify = (message) => { setToast(message); window.setTimeout(() => setToast(''), 1600); };
|
||||
const changeLanguage = (value) => { setLang(value); setGenre(copy[value].genres[0]); const url = new URL(window.location.href); url.searchParams.set('lang', value); window.history.replaceState(null, '', url); document.title = copy[value].title; document.documentElement.lang = value === 'zh' ? 'zh-CN' : value; };
|
||||
return <><header className="topbar"><button className="mobile-only icon-button" onClick={() => setMenu(!menu)} aria-label="Menu"><Menu /></button><a className="brand" href="#home"><span className="brand-symbol"><Play size={13} fill="currentColor" /></span>ymb</a><form className="search-form" onSubmit={(event) => event.preventDefault()}><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={text.search} aria-label={text.search} /><button aria-label="Search"><Search size={19} /></button></form><div className="top-actions"><select value={lang} onChange={(event) => changeLanguage(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 ${menu ? 'open' : ''}`}><nav>{[[Home,'Beranda'],[Compass,'Shorts'],[UserRound,'Langganan'],[Bookmark,'Koleksi'],[History,'Riwayat']].map(([Icon,label]) => <a href="#home" key={label}><Icon size={19} />{label}</a>)}</nav><div className="nav-rule" /><small>GENRE POPULER</small>{text.genres.slice(1).map((item) => <button className="genre-link" key={item} onClick={() => setGenre(item)}>{item}</button>)}</aside><main className="content" id="home"><section className="hero-card"><div className="hero-copy"><small>YMB ORIGINAL</small><h1>{text.hero}<br /><em>{text.accent}</em></h1><p>{text.description}</p><button className="primary-button" onClick={() => notify(text.watch)}>▶ {text.watch}</button></div><div className="hero-art"><img src={`${ASSET}hero.jpg`} alt="YMB drama" /></div></section><section><div className="section-heading"><h2>{text.picks}</h2><span>Lihat semua →</span></div><div className="chip-row">{text.genres.map((item) => <button className={`chip ${genre === item ? 'active' : ''}`} key={item} onClick={() => setGenre(item)}>{item}</button>)}</div><div className="video-grid">{filtered.map((item) => <article className="video-card" key={item[0]} onClick={() => notify(`▶ ${item[0]}`)}><div className="thumb"><img src={`${ASSET}${item[3]}`} alt={item[0]} /><b>{item[4]}</b></div><div className="video-meta"><span className="channel-avatar">{item[1][0]}</span><div><strong>{item[0]}</strong><small>{item[1]}<br />{(Math.random() * 900 + 100).toFixed(0)} rb tayangan · kemarin</small></div></div></article>)}</div></section><section><div className="section-heading"><h2>{text.shorts}</h2><span>Explore →</span></div><div className="shorts-row">{shorts.map((item) => <article className="short-card" key={item[0]} onClick={() => notify(`▶ ${item[0]}`)}><img src={`${ASSET}${item[1]}`} alt={item[0]} /><strong>{item[0]}</strong></article>)}</div></section><section><div className="section-heading"><h2>{text.channels}</h2></div><div className="channel-grid">{channels.map((item) => <article className="channel-card" key={item[0]}><span className="channel-avatar" style={{ background: item[3] }}>{item[2]}</span><div><strong>{item[0]}</strong><small>{item[1]}</small></div><button onClick={(event) => { event.currentTarget.textContent = event.currentTarget.textContent === text.follow ? text.following : text.follow; }}>{text.follow}</button></article>)}</div></section></main><nav className="bottom-nav"><a href="#home"><Home size={19} />Home</a><a href="#shorts"><Compass size={19} />Shorts</a><a href="#channels"><UserRound size={19} />Follow</a><a href="#library"><Bookmark size={19} />Library</a></nav>{toast && <div className="toast">{toast}</div>}{menu && <button className="scrim mobile-only" onClick={() => setMenu(false)} aria-label="Close"><X /></button>}</>;
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
import { 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';
|
||||
|
||||
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>}</>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { SectionHeading } from '../components/SectionHeading';
|
||||
import { videos } from '../data/catalog';
|
||||
import { useAppStore } from '../app/store';
|
||||
|
||||
export function ListingPage({ type }) { const { locale } = useAppStore(); const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library; return <><SectionHeading title={title} /><div className="video-grid">{videos.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
|
||||
@@ -0,0 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { SectionHeading } from '../components/SectionHeading';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { searchCatalog } from '../services/catalogService';
|
||||
|
||||
export function SearchPage() { const { locale } = useAppStore(); const [params] = useSearchParams(); const query = params.get('q') || ''; const results = searchCatalog(query, '', 0); return <><SectionHeading title={query ? `Search results for “${query}”` : locale.picks} /><div className="video-grid">{results.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
|
||||
@@ -0,0 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { videos } from '../data/catalog';
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { useAppStore } from '../app/store';
|
||||
|
||||
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>; }
|
||||
@@ -0,0 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
import { videos } from '../data/catalog';
|
||||
|
||||
export function searchCatalog(query, genre, genreIndex) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const baseGenre = genreIndex > 0 ? ['Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'][genreIndex - 1] : '';
|
||||
return videos.filter((video) => (!baseGenre || video.genre === baseGenre) && (!normalized || `${video.title} ${video.channel} ${video.genre}`.toLowerCase().includes(normalized)));
|
||||
}
|
||||
Reference in new issue
Block a user