diff --git a/README.md b/README.md
index e8c0b1d..2584819 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
index 7cddcb0..220ac8f 100644
--- a/package.json
+++ b/package.json
@@ -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": {}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc8e313..c54dc67 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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:
diff --git a/src/app/App.jsx b/src/app/App.jsx
new file mode 100644
index 0000000..8a17e09
--- /dev/null
+++ b/src/app/App.jsx
@@ -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 }>} />} />} />} />} />} />} />; }
diff --git a/src/app/store.jsx b/src/app/store.jsx
new file mode 100644
index 0000000..560fb3a
--- /dev/null
+++ b/src/app/store.jsx
@@ -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 {children}; }
+export function useAppStore() { return useContext(StoreContext); }
diff --git a/src/components/MediaCard.jsx b/src/components/MediaCard.jsx
new file mode 100644
index 0000000..49db136
--- /dev/null
+++ b/src/components/MediaCard.jsx
@@ -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
{video.duration}{video.channel[0]}{video.title}{video.channel}
{video.views} · 2 hari lalu
;
+}
diff --git a/src/components/SectionHeading.jsx b/src/components/SectionHeading.jsx
new file mode 100644
index 0000000..0e0380d
--- /dev/null
+++ b/src/components/SectionHeading.jsx
@@ -0,0 +1,2 @@
+// Author: fruit.com.cn
+export function SectionHeading({ title, action = 'Lihat semua →' }) { return
{title}
; }
diff --git a/src/data/catalog.js b/src/data/catalog.js
new file mode 100644
index 0000000..b9a198d
--- /dev/null
+++ b/src/data/catalog.js
@@ -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' }
+];
diff --git a/src/enterprise.css b/src/enterprise.css
new file mode 100644
index 0000000..866fba2
--- /dev/null
+++ b/src/enterprise.css
@@ -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}}
diff --git a/src/i18n/index.js b/src/i18n/index.js
new file mode 100644
index 0000000..6cb55bc
--- /dev/null
+++ b/src/i18n/index.js
@@ -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; }
diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx
new file mode 100644
index 0000000..ed43b18
--- /dev/null
+++ b/src/layouts/AppShell.jsx
@@ -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 <>{sidebarOpen && }>;
+}
diff --git a/src/lib/seo.js b/src/lib/seo.js
new file mode 100644
index 0000000..010532f
--- /dev/null
+++ b/src/lib/seo.js
@@ -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 || '/'}`);
+}
diff --git a/src/main.jsx b/src/main.jsx
index 52c5599..78dc0ef 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -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 <>YMB ORIGINAL{text.hero}
{text.accent}
{text.description}
{text.picks}
Lihat semua →{text.genres.map((item) => )}
{filtered.map((item) =>
notify(`▶ ${item[0]}`)}>
{item[4]}{item[1][0]}{item[0]}{item[1]}
{(Math.random() * 900 + 100).toFixed(0)} rb tayangan · kemarin
)}
{text.shorts}
Explore →{shorts.map((item) =>
notify(`▶ ${item[0]}`)}>
{item[0]})}
{text.channels}
{channels.map((item) =>
{item[2]}{item[0]}{item[1]}
)}
{toast && {toast}
}{menu && }>;
-}
createRoot(document.getElementById('root')).render();
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
new file mode 100644
index 0000000..05685c2
--- /dev/null
+++ b/src/pages/HomePage.jsx
@@ -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 <>YMB ORIGINAL{locale.hero}
{locale.accent}
{locale.description}
{locale.genres.map((item) => )}
{items.map((video) => )}
{videos.slice(1, 7).map((video) => )}
{toast && {toast}
}>;
+}
diff --git a/src/pages/ListingPage.jsx b/src/pages/ListingPage.jsx
new file mode 100644
index 0000000..1e97401
--- /dev/null
+++ b/src/pages/ListingPage.jsx
@@ -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 <>{videos.map((video) => )}
>; }
diff --git a/src/pages/SearchPage.jsx b/src/pages/SearchPage.jsx
new file mode 100644
index 0000000..4967d03
--- /dev/null
+++ b/src/pages/SearchPage.jsx
@@ -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 <>{results.map((video) => )}
>; }
diff --git a/src/pages/WatchPage.jsx b/src/pages/WatchPage.jsx
new file mode 100644
index 0000000..7b07288
--- /dev/null
+++ b/src/pages/WatchPage.jsx
@@ -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 
{video.title}
{video.channel} · {video.views}
; }
diff --git a/src/services/catalogService.js b/src/services/catalogService.js
new file mode 100644
index 0000000..3cff8ac
--- /dev/null
+++ b/src/services/catalogService.js
@@ -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)));
+}