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.title}{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 <>
▶ymb
setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
N
{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
event.preventDefault()}> setQuery(event.target.value)} placeholder={text.search} aria-label={text.search} />
N
YMB ORIGINAL

{text.hero}
{text.accent}

{text.description}

YMB drama

{text.picks}

Lihat semua →
{text.genres.map((item) => )}
{filtered.map((item) =>
notify(`▶ ${item[0]}`)}>
{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]}{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}

YMB drama
{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.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))); +}