Compare commits

..
13 Commits
Author SHA1 Message Date
Zhou Weidong dfa9f311e0 fix: 首屏转场不再因减少动效偏好被直接跳过 2026-11-13 13:29:15 +08:00
Zhou Weidong d6da152dec feat: 官网补充公司主体信息与隐私政策、服务条款页
新增 /privacy、/terms 页面,提供英文与印尼文版本,覆盖 Google 登录数据、Play 内购与订阅、账号删除及印尼个人数据保护要求;关于页补充法定主体、注册地址与联系方式,页脚下沉法律入口,下载区仅保留 Google Play。
2026-11-13 13:29:15 +08:00
Zhou Weidong 3d96d82300 docs(ymb): record shorts runtime delivery 2026-11-13 13:23:45 +08:00
Zhou Weidong ebf7886485 fix(ymb): refresh shorts playback sources 2026-11-13 13:23:17 +08:00
Zhou Weidong 535bb57c83 docs(ymb): record ui polish delivery 2026-11-12 18:00:07 +08:00
Zhou Weidong 0ab0b04bbe refactor(ymb): polish responsive youtube interactions 2026-11-12 17:57:57 +08:00
Zhou Weidong 9d9c7e3eec chore: 移除未使用的 BottomNav 组件 2026-11-12 15:14:18 +08:00
Zhou Weidong 2d1f754a46 feat: 移动端沉浸式 Shorts 与播放器/下载徽章体验优化
- 移动端移除底部导航,Shorts 视频改为圆角卡片并适配 iOS 裁剪
- 播放进度条全局贴到视频最底,移动端精简控件(仅播放/全屏)
- 右侧互动栏下移,首页进入播放页增加模糊封面过渡
- 关于页下载入口改为标准 App Store / Google Play 徽章
- 新增自动连播下一集与自动播放被拦截时的开启声音提示
2026-11-12 15:14:09 +08:00
Zhou Weidong 6fd936ac97 fix: 点赞收藏反馈动效不再被 reduce-motion 媒体查询禁用 2026-11-12 12:48:41 +08:00
Zhou Weidong ecfbd3b8b5 refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
2026-11-12 12:32:29 +08:00
Zhou Weidong 8dd5283892 fix: 移动端点击侧边栏菜单项后自动收起抽屉 2026-11-12 10:32:24 +08:00
Zhou Weidong cef39e96c0 fix: 观看进度上报保留长整型剧集ID字符串,避免精度丢失导致记录写入失败 2026-11-11 18:17:23 +08:00
Zhou Weidong eced2890ae feat: complete YouTube-style site experience for YMB
- rebuild home feed with sticky server-driven categories and skeletons
- add full-screen shorts feed with likes, comments, saves and follows
- rebuild watch page with same-drama episode swiping and locked states
- wire search, watch history and episode stats to the app API
- add about page with app store links and an upload video dialog
- add SVG language flags, formatted view counts and site icons
2026-11-11 17:53:45 +08:00
109 changed files with 6457 additions and 508 deletions

No files matched your search

+16 -11
View File
@@ -8,24 +8,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 元数据入口
├── app/ # 应用装配:路由(懒加载)、Locale/UI Provider
├── shared/ # 与业务无关的通用能力
│ ├── api/ # fetch 客户端、拦截器、错误类型、领域无关接口
│ ├── hooks/ # useRequest / useMutation / useToast / useEngagement
│ ├── components/ # AsyncBoundary、VideoGrid、MediaCard、骨架屏
│ ├── lib/ # format、media、pagination、seo、watchProgress
│ ├── data/ # 接口不可用时的演示数据
│ └── styles/ # base / enterprise / 组件覆盖样式
├── entities/ # 领域模型与接口(drama、category)
├── features/ # 业务域:home / shorts / watch / search / history / listing / about
├── layouts/ # AppShell + TopBar / SearchBox / Sidebar
└── i18n/ # ID / EN / zh-CN 语言包
```
依赖方向:`features → entities → shared`,`layouts → shared`;`shared` 不允许反向依赖业务域。
## YouTube 对照的信息架构
- 顶部 AppBar:品牌、全局搜索、语言、通知、用户入口。
- 桌面侧栏:Home、Shorts、Subscriptions、Library、History、Liked videos。
- 首页:Hero 推荐、Topic chips、视频网格、Shorts 横滑、频道推荐。
- 观看页:16:9 播放器、标题/频道信息、Up next 推荐队列。
- 移动端:抽屉侧栏、底部四项导航、两列视频卡与横滑 Shorts。
- 移动端:抽屉侧栏、两列视频卡与横滑 Shorts(无底部导航,视频带圆角)。
## 运行
@@ -43,4 +48,4 @@ pnpm build
- 多语言:印尼语 `?lang=id`、English `?lang=en`、简体中文 `?lang=zh`。
- SEO:动态 `lang/title/description`、canonical、hreflang、Open Graph、Twitter Card、JSON-LD、robots.txt、sitemap.xml。
部署前请将 `ymb.example.com` 替换为正式域名,并同步修改 `index.html`、`app.js`、`robots.txt` 与 `sitemap.xml`。
部署前请将 `ymb.example.com` 替换为正式域名,并同步修改 `index.html`、`public/robots.txt` 与 `public/sitemap.xml`。
-2
View File
@@ -1,2 +0,0 @@
// Author: fruit.com.cn
(function(){'use strict';const d=window.YMB_DATA;const params=new URLSearchParams(location.search);const s={lang:d.locales[params.get('lang')]?params.get('lang'):'id',genre:'',query:params.get('q')||''};const $=(q)=>document.querySelector(q);const t=()=>d.locales[s.lang];const notify=(m)=>{const toast=$('#toast');toast.textContent=m;toast.classList.add('show');setTimeout(()=>toast.classList.remove('show'),1600)};const setMeta=()=>{const locale=t();document.documentElement.lang=locale.lang;document.title=locale.title;$('#page-title').textContent=locale.title;$('#meta-description').content=locale.description;$('#canonical-link').href=`https://ymb.example.com/?lang=${s.lang}`;$('#og-title').content=locale.title;$('#og-description').content=locale.description;$('#twitter-title').content=locale.title;$('#twitter-description').content=locale.description;$('#structured-data').textContent=JSON.stringify({'@context':'https://schema.org','@type':'WebSite',name:'YMB',inLanguage:locale.lang,url:`https://ymb.example.com/?lang=${s.lang}`,description:locale.description,potentialAction:{'@type':'SearchAction',target:`https://ymb.example.com/?lang=${s.lang}&q={search_term_string}`,'query-input':'required name=search_term_string'}});$('#global-search').placeholder=locale.search;$('#language-select').value=s.lang;document.querySelectorAll('[data-i18n]').forEach((node)=>{const key=node.dataset.i18n;if(locale[key])node.textContent=locale[key]})};const updateUrl=()=>{const query=new URLSearchParams({lang:s.lang});if(s.query)query.set('q',s.query);history.replaceState(null,'',`?${query.toString()}`)};const render=()=>{const locale=t();const genres=locale.genres;const activeGenre=s.genre?d.locales.id.genres[genres.indexOf(s.genre)]:'';$('#genre-nav').innerHTML=genres.slice(1).map((g)=>`<a href="#home" data-genre="${g}">${g}</a>`).join('');$('#filter-chips').innerHTML=genres.map((g)=>`<button class="chip ${g===s.genre?'active':''}" data-genre="${g}">${g}</button>`).join('');const rows=d.videos.filter((v)=>(!activeGenre||v[2]===activeGenre)&&(!s.query||v.join(' ').toLowerCase().includes(s.query)));$('#video-grid').innerHTML=rows.map((v,i)=>`<article class="video-card" data-play="${v[0]}"><div class="thumb"><img src="${v[3]}" alt="${v[0]}" loading="lazy"><span class="duration">${v[4]}</span></div><div class="video-meta"><span class="channel-avatar">${v[1][0]}</span><div><p class="video-title">${v[0]}</p><p class="video-sub">${v[1]}<br>${(i+4)*137} rb tayangan · ${i+1} hari lalu</p></div></div></article>`).join('')||`<p>${locale.empty}</p>`;$('#shorts-row').innerHTML=d.shorts.map((v)=>`<article class="short-card" data-play="${v[0]}"><img src="${v[1]}" alt="${v[0]}" loading="lazy"><span class="short-title">${v[0]}</span></article>`).join('');$('#channel-grid').innerHTML=d.channels.map((v)=>`<article class="channel-card"><span class="channel-avatar" style="background:${v[3]}">${v[2]}</span><div><p>${v[0]}</p><span>${v[1]}</span></div><button class="follow-button" data-follow>${locale.follow}</button></article>`).join('');setMeta()};document.addEventListener('click',(e)=>{const genre=e.target.closest('[data-genre]');if(genre){e.preventDefault();s.genre=genre.dataset.genre;render()}const play=e.target.closest('[data-play]');if(play)notify(`${t().playing}${play.dataset.play}`);const follow=e.target.closest('[data-follow]');if(follow){const isFollowing=follow.textContent===t().follow;follow.textContent=isFollowing?t().following:t().follow;notify(isFollowing?t().added:t().removed)}if(e.target.closest('#menu-toggle'))$('#sidebar').classList.toggle('open')});$('#search-form').addEventListener('submit',(e)=>{e.preventDefault();s.query=$('#global-search').value.trim().toLowerCase();updateUrl();render()});$('#language-select').addEventListener('change',(e)=>{s.lang=e.target.value;s.genre='';updateUrl();render()});$('#global-search').value=s.query;render()})();
-11
View File
@@ -1,11 +0,0 @@
// Author: fruit.com.cn
window.YMB_DATA={
locales:{
id:{lang:'id',title:'YMB · Cerita pendek, emosi besar',description:'YMB — cerita pendek, emosi besar. Temukan drama pendek terbaik dan episode baru setiap hari.',search:'Cari drama, aktor, atau genre',original:'YMB ORIGINAL',heroTitle:'Cerita pendek,',heroAccent:'emosi besar.',heroDescription:'Temukan drama yang bikin kamu terus menonton. Episode baru setiap hari.',watchNow:'▶ Mulai menonton',save:'♡ Simpan',recommendation:'REKOMENDASI UNTUKMU',weeklyPicks:'Drama pilihan minggu ini',seeAll:'Lihat semua →',shortsTitle:'Cerita dalam satu tarikan napas',explore:'Jelajahi →',channels:'CHANNEL UNTUKMU',followStories:'Ikuti cerita favoritmu',manage:'Kelola →',follow:'Ikuti',following:'Mengikuti',added:'Channel ditambahkan',removed:'Channel dihapus',playing:'Memutar: ',empty:'Belum ada drama yang cocok.',genres:['Semua','Romansa','Balas dendam','Keluarga','Rahasia','Kisah kedua']},
en:{lang:'en',title:'YMB · Short stories, big emotions',description:'YMB — short stories, big emotions. Discover the best short dramas and new episodes every day.',search:'Search dramas, actors, or genres',original:'YMB ORIGINAL',heroTitle:'Short stories,',heroAccent:'big emotions.',heroDescription:'Find dramas that keep you watching. New episodes every day.',watchNow:'▶ Watch now',save:'♡ Save',recommendation:'RECOMMENDED FOR YOU',weeklyPicks:'Drama picks this week',seeAll:'See all →',shortsTitle:'Stories in one breath',explore:'Explore →',channels:'CHANNELS FOR YOU',followStories:'Follow your favorite stories',manage:'Manage →',follow:'Follow',following:'Following',added:'Channel followed',removed:'Channel unfollowed',playing:'Playing: ',empty:'No dramas match your search.',genres:['All','Romance','Revenge','Family','Secrets','Second chance']},
zh:{lang:'zh-CN',title:'YMB · 短故事,大情绪',description:'YMB——短故事,大情绪。发现精彩短剧,每天更新新剧集。',search:'搜索短剧、演员或题材',original:'YMB 原创',heroTitle:'短故事,',heroAccent:'大情绪。',heroDescription:'发现让你停不下来的短剧,每天都有新集数。',watchNow:'▶ 立即观看',save:'♡ 收藏',recommendation:'为你推荐',weeklyPicks:'本周精选短剧',seeAll:'查看全部 →',shortsTitle:'一口气看完的故事',explore:'去探索 →',channels:'为你推荐的频道',followStories:'关注你喜欢的故事',manage:'管理 →',follow:'关注',following:'已关注',added:'已关注频道',removed:'已取消关注',playing:'正在播放:',empty:'没有找到匹配的短剧。',genres:['全部','爱情','复仇','家庭','秘密','重生']}
},
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']],
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']],
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']]
};
@@ -0,0 +1,211 @@
# YMB Site 企业级重构方案
> 目标:在不改变现有视觉与交互的前提下,把「演示工程」重构成可按业务域扩展的前端工程。
> 约束:纯重构,功能与文案不变;每个阶段结束必须 `pnpm build` + `pnpm lint` 通过;剧集 ID 保持字符串(19 位长整型)。
## 一、现状诊断
### 1. 目录没有业务边界
当前 `src/` 按技术类型平铺:`pages/`、`components/`、`services/`、`hooks/`、`lib/`、`data/`。
- 首页、Shorts、播放页、搜索页共享同一批 `services` 与 `components`,无法知道改一个页面会影响谁。
- 页面与接口调用直接耦合:`HomePage`、`SearchPage`、`ListingPage` 各自写一遍 `normalizePage(...).list.map(normalizeDrama)`。
- `data/catalog.js` 是演示兜底数据,和真实 API 层混在同一层级。
### 2. 请求层只有 24 行,没有封装
`src/services/httpClient.js` 现状:
- header、超时、JSON 解析、业务码判断全部内联在一个 `request()` 里;
- 错误统一 `throw new Error(payload.message)`,丢失 HTTP 状态、业务码、请求路径,超时/断网/业务失败无法区分;
- 没有拦截器链,后续加 token、埋点、错误上报只能继续往里堆 if;
- `appApi.js` 用字符串拼 query(`?pageIndex=1&pageSize=20`),16 个端点挤在一个对象里。
### 3. 数据与状态层薄弱
- `useApi` 只有 `active` 标志,没有 `AbortController`、没有 `refetch`、没有重试;错误只有一个 `message` 字符串。
- 点赞/收藏在 `ShortsFeedPage` 和 `WatchPage` 各写了一遍(含 try/catch + console.error),没有 mutation 抽象。
- `store.jsx` 把 locale / 搜索词 / 侧栏状态塞进一个 Context,搜索框每次输入都会让整棵树重渲染。
- `normalizers.js` 同时承担媒体 URL 解析、分页解包、播放进度模型,职责混杂。
### 4. 组件没有抽离
| 现状 | 问题 |
| --- | --- |
| `layouts/AppShell.jsx` 134 行 | 顶栏 + 搜索 + 创建菜单 + 侧栏 + 底部导航 + 上传弹窗状态全在一个组件 |
| `pages/ShortsFeedPage.jsx` 237 行 | `ShortSlide`、点赞/收藏、分页游标、滚动监听、键盘事件混在一起 |
| `pages/WatchPage.jsx` 235 行 | 播放源、滑动手势、剧集列表、统计、评论、分享混在一起 |
| 各页面 | loading / error / empty 三态重复手写 |
| `components/Skeletons.jsx` 104 行 | 6 个骨架屏塞在一个文件 |
### 5. 样式与路由
- `src/styles.css`(旧版全站样式,含 `@font-face`)、`enterprise.css`、`youtube-overrides.css` 三层叠加,没有 tokens 与层级约定。
- `src/app/App.jsx` 单行全量 import,无懒加载;构建主 chunk 907KB,Vite 已提示超限。
## 二、目标目录结构
```text
src/
├── app/ # 应用装配层
│ ├── App.jsx # 只做 Provider + Router 组装
│ ├── router.jsx # 路由表(React.lazy + Suspense)
│ └── providers/
│ ├── AppProviders.jsx
│ ├── LocaleProvider.jsx # 语言 + SEO
│ └── UiProvider.jsx # 侧栏 / 搜索词 / toast
├── shared/ # 与业务无关的通用能力
│ ├── api/
│ │ ├── config.js # baseURL、超时、平台常量
│ │ ├── errors.js # ApiError / TimeoutError / NetworkError
│ │ ├── client.js # fetch 核心 + 拦截器管线
│ │ ├── interceptors.js # 设备头 / JSON / 业务码 / 错误归一
│ │ └── index.js
│ ├── hooks/
│ │ ├── useRequest.js # 查询:取消、重试、refetch
│ │ ├── useMutation.js # 写操作:pending、错误、乐观更新
│ │ └── useToast.js
│ ├── components/
│ │ ├── AsyncBoundary.jsx # 统一 loading / error / empty / retry
│ │ ├── VideoGrid.jsx
│ │ ├── EmptyState.jsx
│ │ ├── ErrorState.jsx
│ │ ├── Toast.jsx
│ │ └── skeletons/index.jsx
│ ├── lib/ # format / seo / media
│ └── styles/ # tokens / base / layout / 组件样式
├── entities/ # 领域模型与数据转换
│ └── drama/
│ ├── model.js # normalizeDrama / normalizeProgress
│ └── api.js # 剧集、分集、播放、统计
├── features/ # 业务域(页面 + 域内组件 + 域内 hook)
│ ├── home/ HomePage.jsx / CategoryBar.jsx / useHomeFeed.js
│ ├── shorts/ ShortsFeedPage.jsx / ShortSlide.jsx / useShortsFeed.js
│ ├── watch/ WatchPage.jsx / EpisodeRail.jsx / useEpisodePlayback.js
│ ├── search/ SearchPage.jsx / useSearchResults.js
│ ├── history/ HistoryPage.jsx
│ ├── listing/ ListingPage.jsx
│ └── about/ AboutPage.jsx + i18n/about.js
├── layouts/
│ ├── AppShell.jsx # 只做骨架拼装
│ └── components/
│ ├── TopBar.jsx
│ ├── SearchBox.jsx
│ ├── CreateMenu.jsx
│ ├── Sidebar.jsx
│ └── BottomNav.jsx
├── i18n/
│ ├── index.js # 聚合 + getLocale
│ └── locales/{id,en,zh}.js
└── main.jsx
```
依赖方向固定为:`features → entities → shared`,`layouts → shared`;禁止 `shared` 反向引用 `features`,禁止页面直接调用 `fetch`。
## 三、请求层设计(重点)
### 1. 统一错误类型
```js
// shared/api/errors.js
export class ApiError extends Error {
constructor(message, { status = 0, code = null, path = '', cause } = {}) { ... }
}
export class TimeoutError extends ApiError {}
export class NetworkError extends ApiError {}
```
页面只判断 `error instanceof TimeoutError` / `error.status`,不再解析字符串。
### 2. 拦截器管线
```js
// shared/api/client.js
const client = createHttpClient({ baseURL, timeout: 12000 });
client.useRequest((config) => ({
...config,
headers: { 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...config.headers },
}));
client.useResponse((response) => response); // 预留:解包、埋点
client.useError((error) => { /* 归一化 + 401 处理入口 */ throw error; });
client.get('/app/home/feed');
client.post('/app/likes/toggle', { targetType, targetId });
```
- 请求拦截器:设备 ID、平台头、Content-Type、语言头(预留)。
- 响应拦截器:`code === 0/200` 时解包 `data`;否则抛 `ApiError(code, message)`。
- 错误拦截器:`AbortError` → 取消(静默);超时 → `TimeoutError`;`fetch` 异常 → `NetworkError`;HTTP 非 2xx → `ApiError(status)`。
- 所有查询共用 `AbortSignal`,组件卸载即取消,避免切页后 setState。
### 3. 端点按域拆分
```text
entities/drama/api.js getDrama / getEpisodes / getPlayback / getStats
features/home/api.js getHomeFeed / getCategories / getCategoryDramas
features/search/api.js search
features/history/api.js getRecent / clearHistory / reportProgress
features/shared? likes / favorites / comments → shared/api/engagement.js
```
`src/services/appApi.js` 在迁移期保留为兼容门面,阶段 6 删除。
## 四、组件抽离清单
| 抽离项 | 来源 | 去向 |
| --- | --- | --- |
| `TopBar` / `SearchBox` / `CreateMenu` / `Sidebar` / `BottomNav` | `AppShell.jsx` | `layouts/components/` |
| `ShortSlide` | `ShortsFeedPage.jsx` 内的私有组件 | `features/shorts/components/ShortSlide.jsx` |
| `EpisodeRail` | `WatchPage.jsx` 右侧剧集列表 | `features/watch/components/EpisodeRail.jsx` |
| `AsyncBoundary` | 4 个页面重复的三态 | `shared/components/AsyncBoundary.jsx` |
| `VideoGrid` | `HomePage` / `ListingPage` / `SearchPage` 重复网格 | `shared/components/VideoGrid.jsx` |
| `EmptyState` / `ErrorState` | 各页面 `api-note` / `search-empty` | `shared/components/` |
| `Toast` | Shorts / Watch 各自实现 | `shared/components/Toast.jsx` + `useToast` |
| 骨架屏 | `Skeletons.jsx` | `shared/components/skeletons/`(按页面一个文件 + index 聚合) |
| 点赞/收藏交互 | Shorts 与 Watch 重复 | `shared/hooks/useEngagement.js` |
`AsyncBoundary` 使用方式:
```jsx
<AsyncBoundary
loading={request.loading}
error={request.error}
empty={!items.length}
skeleton={<VideoGridSkeleton />}
emptyText={locale.empty}
onRetry={request.refetch}
>
<VideoGrid items={items} />
</AsyncBoundary>
```
## 五、分阶段实施
| 阶段 | 内容 | 验收 |
| --- | --- | --- |
| P0 | 记录基线:`pnpm build` / `pnpm lint` 结果,页面清单截图 | 基线存档 |
| P1 | `shared/api`:错误类型、client、拦截器、config;`appApi` 改为基于 client | build + lint,接口行为不变 |
| P2 | `entities/drama`:模型归一化、分页、媒体 URL 收敛;页面改用 | 首页 / 列表 / 搜索数据一致 |
| P3 | `useRequest` / `useMutation` / `useToast` / `useEngagement`;替换 `useApi` 与重复逻辑 | 点赞、收藏、评论、上传弹窗回归 |
| P4 | 拆 `AppShell` 为 TopBar/SearchBox/CreateMenu/Sidebar/BottomNav;路由懒加载 | 侧栏收起、移动端抽屉、搜索跳转回归 |
| P5 | 页面迁入 `features/`,抽 ShortSlide、EpisodeRail、VideoGrid、AsyncBoundary、骨架屏 | 首页 / Shorts / 播放 / 搜索 / 历史 / 关于全量回归 |
| P6 | 样式分层(tokens/base/legacy 分离),删除旧兼容层与 `services/appApi.js` | 视觉 diff 无变化,build + lint 通过 |
每个阶段独立提交,提交信息遵循仓库现有 `feat:` / `refactor:` 风格。
## 六、风险与对策
1. **视觉回归**:P4/P5 只调整结构,CSS 类名与覆盖顺序保持不变;阶段结束对照截图。
2. **Shorts 滑动性能**:`ShortsFeedPage` 的 IntersectionObserver、scroll-snap、键盘/滚轮逻辑整体迁移,不重写。
3. **ID 精度**:所有 episode/drama ID 继续保持 `String()`,禁止 `Number()`;在 `entities/drama/model.js` 注释固化。
4. **Context 拆分**:拆 locale 与 ui 后,确认切语言仍能刷新全站文案、搜索输入不再触发页面重渲染。
5. **接口兼容**:P1/P2 期间 `appApi` 门面保留,页面可逐步迁移,避免一次性大爆炸。
## 七、验收标准
- `pnpm build` 通过,主包因路由懒加载下降(当前 907KB)。
- `pnpm lint` 0 error。
- 路由回归:`/`、`/shorts`、`/watch/:id`、`/search`、`/history`、`/subscriptions`、`/library`、`/about`。
- 交互回归:上下滑切集、Shorts 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
- 目录符合本文第二节,跨层依赖检查无违规。
@@ -0,0 +1,48 @@
# YMB 官网高级 UI 与交互优化计划
## 目标
- 在不改变现有深色品牌基调、真实接口和信息架构的前提下,提升官网的视觉质感与交互连贯性。
- 桌面端重点优化导航层级、内容卡片反馈、搜索与语言切换;移动端重点优化触控尺寸、抽屉与沉浸式内容浏览。
- 保证加载、空态、错误态与播放状态都有明确反馈,并尊重系统减少动画设置。
## 实施步骤
1. 审计首页、Shorts、观看页、搜索页在桌面与移动视口下的布局和控制台状态。
2. 修复影响内容展示、可访问性和交互流畅度的问题。
3. 统一视觉 token、悬停/按压/焦点反馈、卡片动效、骨架加载与移动端安全区适配。
4. 验证真实接口、图片、Shorts 播放和页面跳转不回退。
5. 运行构建与差异检查,回填验证结果。
## 范围
- `src/components/**`
- `src/layouts/**`
- `src/pages/**`
- `src/styles/**`
- `src/app/**`
- 必要的测试与本计划文件
## 风险与约束
- 不修改 FruitServer 接口契约,不把匿名浏览改成登录后可见。
- 不改变用户已确认的深色背景与品牌主色,只调整层次、间距、透明度和反馈。
- 不引入重量级 UI 框架,优先复用现有组件与 CSS token。
- 当前分支为 `dev`,只提交本轮站点文件,不夹带其他仓库改动。
## 验证
- `pnpm build`
- `git diff --check`
- 桌面端 1440px:首页、Shorts、观看页、搜索与语言菜单
- 移动端 390×844:侧栏抽屉、Shorts 滑动、观看页与安全区
- 浏览器控制台无新增 error
## 实施回执
- 已完成:保留深色 YMB 品牌、真实接口和无底部导航约束,未修改 FruitServer 契约。
- 样式:移除全局 `font-weight: 400 !important`,恢复标题/按钮/元信息层级;增加页面最小宽度与横向溢出保护;移动端双列网格和 Shorts 横向列表增加宽度约束。
- 交互:补充键盘 `:focus-visible` 焦点环、搜索聚焦反馈、触屏按压反馈;创建菜单/语言菜单增加轻量入场动画;侧栏与内容区收起/展开增加平滑过渡;尊重 `prefers-reduced-motion`。
- 真实链路复核:前端 `http://127.0.0.1:4179/`、后端 `/app/categories`、`/app/home/feed`、`/app/dramas/1`、`/app/episodes/1/play` 均可达;播放地址响应 `application/vnd.apple.mpegurl`,图片 `hero.jpg` 响应 `image/jpeg`。
- 门禁:`pnpm lint` 通过;`pnpm build` 通过(仅保留既有 chunk size warning);`git diff --check` 通过。
- 当前状态:本轮文件已提交并推送到 `origin/dev`(提交 `0ab0b04`);未改后端、未引入底部导航,已发送完成通知。
@@ -0,0 +1,42 @@
# YMB 站点运行态与 Shorts 体验优化计划
## 目标
- 让 Shorts 与观看页统一使用 `/app/episodes/{id}/play` 的最新签名播放地址。
- 优化播放等待、播放失败、空结果和移动端手势反馈,减少“像演示数据”的体验。
- 保持现有深色品牌、YouTube 式信息架构、多语言、匿名浏览和无底部导航。
## 范围
- `src/features/shorts/**`
- `src/shared/components/player/**`
- `src/features/search/**`
- 必要的共享样式与本计划文件
## 约束
- 不修改 FruitServer 接口契约,不自行拼接签名播放地址。
- 播放地址只使用接口返回值;预览流只作为接口失败时的兼容回退。
- 不引入新的状态管理或重量级 UI 依赖。
- 保持当前分支 `dev`,只提交 YMB 子项目本轮文件。
## 验证
- `pnpm lint`
- `pnpm build`
- `git diff --check`
- 本地验证 `/app/feed`、`/app/episodes/{id}/play`、HLS 清单响应和前端首页可访问。
## 风险 / 待确认
- Shorts 每次激活视频会请求一次播放票据;使用现有请求取消机制,避免切换页面后写入失效状态。
- 当前浏览器自动播放策略仍可能要求静音启动,保留现有声音提示和手动开启逻辑。
## 实施回执
- 已完成:Shorts 当前激活视频改为调用 `/app/episodes/{id}/play` 获取最新签名地址,切换激活项时复用 `useRequest` 的取消机制;接口失败时仅回退到推荐流已有播放地址。
- 媒体解析:图片 `resolveMedia` 与播放 `resolvePlaybackUrl` 分离,缺失播放地址返回空字符串,不再把 `hero.jpg` 送入 HLS;锁定集和无地址分集保持占位状态。
- 交互:Shorts 当前视频播放结束后自动进入下一条,手势、键盘方向键和桌面箭头控制保持不变;等待和播放失败状态在卡片内可见。
- 真实链路:`/app/feed?size=6` 返回 6 条并带游标;首条 `/app/episodes/{id}/play` 返回 `accessible=true`、签名 `playUrl` 和多清晰度;HLS 清单响应 `application/vnd.apple.mpegurl`;前端 `http://127.0.0.1:4179/` 返回 200。
- 门禁:`pnpm lint` 通过;`pnpm build` 通过(仅有既有大 chunk 提示);`git diff --check` 通过。
- 当前状态:本轮源码与计划文件已提交并推送到 `origin/dev`(提交 `ebf7886`);未修改 FruitServer 契约,未增加底部导航。
+32
View File
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
import js from '@eslint/js';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{ ignores: ['dist/**', 'node_modules/**'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser },
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { react, 'react-hooks': reactHooks },
settings: { react: { version: 'detect' } },
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
{
files: ['vite.config.js', 'eslint.config.js'],
languageOptions: { globals: { ...globals.node } },
},
];
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

+3
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f0f0f" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="description" content="YMB — short stories, big emotions." />
<meta name="robots" content="index,follow,max-image-preview:large" />
<link rel="canonical" href="https://ymb.example.com/" />
+9 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@vitejs/plugin-react": "latest",
@@ -16,5 +17,12 @@
"react-dom": "latest",
"react-router-dom": "latest",
"vite": "latest"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"eslint": "^9.39.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.14.0"
}
}
+2245
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File
File renamed without changes.
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Author: fruit.com.cn -->
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>http://39.162.50.99:3007/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
<url><loc>http://39.162.50.99:3007/about</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>http://39.162.50.99:3007/privacy</loc><changefreq>yearly</changefreq><priority>0.5</priority></url>
<url><loc>http://39.162.50.99:3007/terms</loc><changefreq>yearly</changefreq><priority>0.5</priority></url>
</urlset>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Author: fruit.com.cn -->
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://ymb.example.com/?lang=id</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
<url><loc>https://ymb.example.com/?lang=en</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>https://ymb.example.com/?lang=zh</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
</urlset>
+12 -9
View File
@@ -1,11 +1,14 @@
// 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';
import { ShortsFeedPage } from '../pages/ShortsFeedPage';
import { BrowserRouter } from 'react-router-dom';
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
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>; }
export function App() {
return (
<BrowserRouter>
<AppProviders>
<AppRouter />
</AppProviders>
</BrowserRouter>
);
}
+90
View File
@@ -0,0 +1,90 @@
// Author: fruit.com.cn
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { getLocale } from '../../i18n';
import { detectLocaleFromIp } from '../../shared/lib/geo';
import { applySeo } from '../../shared/lib/seo';
const LocaleContext = createContext(null);
const UiContext = createContext(null);
const LANG_STORAGE_KEY = 'ymb.lang';
const SUPPORTED_LANGS = ['id', 'en', 'zh'];
function readUrlLang() {
const value = new URLSearchParams(window.location.search).get('lang');
return SUPPORTED_LANGS.includes(value) ? value : '';
}
function readStoredLang() {
try {
const value = window.localStorage.getItem(LANG_STORAGE_KEY);
return SUPPORTED_LANGS.includes(value) ? value : '';
} catch {
return '';
}
}
function readNavigatorLang() {
const code = (navigator.language || '').toLowerCase();
if (code.startsWith('id')) return 'id';
if (code.startsWith('zh')) return 'zh';
return 'en';
}
function detectInitialLang() {
return readUrlLang() || readStoredLang() || readNavigatorLang();
}
export function AppProviders({ children }) {
const [lang, setLangState] = useState(detectInitialLang);
const [query, setQuery] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 760);
const userSelectedRef = useRef(false);
const locale = getLocale(lang);
// 首次进入且没有明确偏好时,按 IP 归属地自动匹配语言
useEffect(() => {
if (readUrlLang() || readStoredLang()) return undefined;
let active = true;
detectLocaleFromIp().then((detected) => {
if (active && detected && !userSelectedRef.current) setLangState(detected);
});
return () => {
active = false;
};
}, []);
const setLang = useCallback((next) => {
userSelectedRef.current = true;
setLangState(next);
try {
window.localStorage.setItem(LANG_STORAGE_KEY, next);
} catch {
// 隐私模式下写入失败时仍保证本次会话生效
}
}, []);
useEffect(() => {
applySeo(locale, window.location.pathname);
}, [lang, locale]);
const localeValue = useMemo(() => ({ lang, locale, setLang }), [lang, locale, setLang]);
const uiValue = useMemo(() => ({ query, setQuery, sidebarOpen, setSidebarOpen }), [query, sidebarOpen]);
return (
<LocaleContext.Provider value={localeValue}>
<UiContext.Provider value={uiValue}>{children}</UiContext.Provider>
</LocaleContext.Provider>
);
}
export function useLocale() {
const value = useContext(LocaleContext);
if (!value) throw new Error('useLocale must be used within AppProviders');
return value;
}
export function useUi() {
const value = useContext(UiContext);
if (!value) throw new Error('useUi must be used within AppProviders');
return value;
}
+33
View File
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
import { lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
import { AppShell } from '../layouts/AppShell';
const HomePage = lazy(() => import('../features/home/HomePage').then((module) => ({ default: module.HomePage })));
const ShortsFeedPage = lazy(() => import('../features/shorts/ShortsFeedPage').then((module) => ({ default: module.ShortsFeedPage })));
const WatchPage = lazy(() => import('../features/watch/WatchPage').then((module) => ({ default: module.WatchPage })));
const SearchPage = lazy(() => import('../features/search/SearchPage').then((module) => ({ default: module.SearchPage })));
const HistoryPage = lazy(() => import('../features/history/HistoryPage').then((module) => ({ default: module.HistoryPage })));
const ListingPage = lazy(() => import('../features/listing/ListingPage').then((module) => ({ default: module.ListingPage })));
const AboutPage = lazy(() => import('../features/about/AboutPage').then((module) => ({ default: module.AboutPage })));
const LegalPage = lazy(() => import('../features/legal/LegalPage').then((module) => ({ default: module.LegalPage })));
export function AppRouter() {
return (
<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={<HistoryPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/privacy" element={<LegalPage type="privacy" />} />
<Route path="/privacy-policy" element={<LegalPage type="privacy" />} />
<Route path="/terms" element={<LegalPage type="terms" />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/watch/:id" element={<WatchPage />} />
</Route>
</Routes>
);
}
-9
View File
@@ -1,9 +0,0 @@
// Author: fruit.com.cn
import { createContext, useContext, useMemo, useState } from 'react';
import { getLocale } from '../i18n';
import { applySeo } from '../lib/seo';
import { useEffect } from 'react';
const StoreContext = createContext(null);
export function StoreProvider({ children }) { const initial = new URLSearchParams(location.search).get('lang'); const [lang, setLangState] = useState(initial || 'id'); const [query, setQuery] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 760); const setLang = (next) => { setLangState(next); const url = new URL(location.href); url.searchParams.set('lang', next); history.replaceState(null, '', url); }; const locale = getLocale(lang); useEffect(() => applySeo(locale, location.pathname), [lang, locale]); const value = useMemo(() => ({ lang, locale, setLang, query, setQuery, sidebarOpen, setSidebarOpen }), [lang, locale, query, sidebarOpen]); return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; }
export function useAppStore() { return useContext(StoreContext); }
-10
View File
@@ -1,10 +0,0 @@
// 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>;
}
-8
View File
@@ -1,8 +0,0 @@
// Author: fruit.com.cn
import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
export function MediaCard({ video, compact = false }) {
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>{compact ? <Link to={`/watch/${video.id}`} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
}
-2
View File
@@ -1,2 +0,0 @@
// Author: fruit.com.cn
export function SectionHeading({ title, action = 'Lihat semua →' }) { return <div className="section-heading"><h2>{title}</h2><button>{action}</button></div>; }
-11
View File
@@ -1,11 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
const ref = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState('');
useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; setReady(false); setError(''); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onError = (_event, data) => { if (data?.fatal) { const message = `${data.details || 'Video stream error'} · ${data.response?.code || ''}`; console.error('YMB HLS ERROR', data); setError(message); } }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else setError('This browser does not support HLS'); return () => { video.removeEventListener('error', onVideoError); hls.destroy(); video.pause(); }; }, [src, muted]);
useEffect(() => { const video = ref.current; if (!video || !ready) return; if (autoPlay) video.play().catch((playError) => { if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`); }); else video.pause(); }, [autoPlay, ready]);
const playNow = () => { const video = ref.current; if (video) { video.muted = false; video.play().catch((playError) => setError(playError.message)); } };
return <div className="video-player-wrap"><video ref={ref} className="video-player" poster={poster} controls playsInline muted={muted} onTimeUpdate={(event) => onProgress?.(event.currentTarget.currentTime)} />{!ready && !error && <button className="video-play-button" onClick={playNow}>▶</button>}{error && <button className="video-error-button" onClick={playNow}>{error} · 点击重试</button>}</div>;
}
+8
View File
@@ -0,0 +1,8 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
import { toQuery } from '../../shared/api/query';
export const categoryApi = {
list: (options) => http.get('/app/categories', options),
dramas: (id, pageIndex = 1, pageSize = 20, options) => http.get(`/app/categories/${encodeURIComponent(id)}/dramas${toQuery({ pageIndex, pageSize })}`, options),
};
+11
View File
@@ -0,0 +1,11 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
import { toQuery } from '../../shared/api/query';
export const dramaApi = {
get: (id, options) => http.get(`/app/dramas/${encodeURIComponent(id)}`, options),
playback: (episodeId, options) => http.get(`/app/episodes/${encodeURIComponent(episodeId)}/play`, options),
stats: (episodeId, options) => http.get(`/app/episodes/${encodeURIComponent(episodeId)}/stats`, options),
homeFeed: (options) => http.get('/app/home/feed', options),
feed: ({ types = '', categoryId = '', cursor, size = 10 } = {}, options) => http.get(`/app/feed${toQuery({ size, types, categoryId, cursor })}`, options),
};
+52
View File
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
import { getImage, resolveMedia, resolvePlaybackUrl } from '../../shared/lib/media';
import { getText } from '../../shared/lib/text';
export function normalizeDrama(item = {}, lang = 'id') {
const id = String(item.id ?? item.dramaId ?? item.code ?? '');
const episodes = item.episodes || item.episodeList || (item.preview ? [item.preview] : []);
const preview = item.preview || episodes[0] || null;
return {
id,
title: getText(item.titleLocales || 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) || getText(item.types?.[0], lang),
image: getImage(item),
duration: item.duration || preview?.duration || '',
views: item.views || item.viewCount || '',
likeCount: Number(item.likeCount) || 0,
favoriteCount: Number(item.favoriteCount) || 0,
commentCount: Number(item.commentCount) || 0,
description: getText(item.synopsisLocales, lang) || '',
episodes: episodes.map((episode) => ({ ...episode, playUrl: resolvePlaybackUrl(episode.playUrl) })),
preview: preview ? { ...preview, playUrl: resolvePlaybackUrl(preview.playUrl) } : null,
};
}
export function normalizeProgress(item = {}, lang = 'id') {
const durationMs = Number(item.durationMs) || 0;
const positionMs = Number(item.positionMs) || 0;
return {
id: String(item.dramaId ?? ''),
episodeId: String(item.episodeId ?? ''),
title: getText(item.titleLocales, lang) || item.dramaTitle || 'YMB Drama',
image: resolveMedia(item.dramaCoverLandscape || item.dramaCover || ''),
episodeNumber: item.episodeNumber == null ? null : Number(item.episodeNumber),
totalEpisodes: item.totalEpisodes == null ? null : Number(item.totalEpisodes),
positionMs,
durationMs,
fraction: durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0,
viewCount: item.viewCount,
finished: Boolean(item.finished),
updatedAt: item.updatedAt || '',
};
}
export function uniqueDramas(items) {
const seen = new Set();
return items.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}
+140
View File
@@ -0,0 +1,140 @@
// Author: fruit.com.cn
import { ArrowRight, Clapperboard, Cpu, Play, Sparkles, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { COMPANY } from '../../shared/data/company';
import { aboutContent } from './content';
const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG';
const PILLARS = [
{ key: 'join', icon: Users },
{ key: 'produce', icon: Clapperboard },
{ key: 'compute', icon: Cpu },
];
function GooglePlayLogo() {
return (
<svg width="22" height="24" viewBox="0 0 512 512" aria-hidden="true">
<path fill="#00A0FF" d="M64 40v432l248-216z" />
<path fill="#00E676" d="M64 40l248 216 80-80z" />
<path fill="#FF3A44" d="M64 472l248-216 80 80z" />
<path fill="#FFCE00" d="M312 256l80-80 56 56c14 14 14 34 0 48l-56 56z" />
</svg>
);
}
function CompanySection({ copy }) {
return (
<section className="about-section">
<h2>{copy.companyTitle}</h2>
<div className="company-card">
<div className="company-item">
<span>{copy.companyLegalName}</span>
<strong>{COMPANY.legalName}</strong>
</div>
<div className="company-item">
<span>{copy.companyAddress}</span>
<strong>{COMPANY.address}</strong>
</div>
<div className="company-item">
<span>{copy.companyRegistration}</span>
<strong>NIB {COMPANY.nib}</strong>
</div>
<div className="company-item">
<span>{copy.companyEmail}</span>
<a href={`mailto:${COMPANY.email}`}>{COMPANY.email}</a>
</div>
<div className="company-item">
<span>{copy.companyPhone}</span>
<a href={`tel:${COMPANY.phoneHref}`}>{COMPANY.phone}</a>
</div>
</div>
</section>
);
}
export function AboutPage() {
const { locale } = useLocale();
const copy = aboutContent[locale.code] || aboutContent.en;
return (
<div className="about-page">
<section className="about-hero">
<div className="about-hero-copy">
<span className="about-kicker">{copy.kicker}</span>
<h1>{copy.heroTitle}</h1>
<p>{copy.heroText}</p>
<div className="about-hero-actions">
<Link to="/" className="about-primary"><Sparkles size={16} />{copy.explore}</Link>
<Link to="/shorts" className="about-secondary"><Play size={16} fill="currentColor" />{copy.watchShorts}</Link>
</div>
<div className="about-stores">
<span className="about-stores-label">{copy.downloadLabel}</span>
<div className="about-stores-row">
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<GooglePlayLogo />
<span className="store-badge-copy"><small>{copy.androidSmall}</small><b>Google Play</b></span>
</a>
</div>
</div>
<div className="about-values">{copy.values.map((value) => <span key={value}>{value}</span>)}</div>
</div>
<div className="about-hero-visual" aria-hidden="true">
<span className="about-float about-float-a">{copy.joinTitle}</span>
<span className="about-float about-float-b">{copy.produceTitle}</span>
<span className="about-float about-float-c">{copy.computeTitle}</span>
<div className="about-phone">
<span className="about-phone-glow" />
<span className="about-phone-logo">ymb</span>
<span className="about-phone-play"><Play size={26} fill="currentColor" /></span>
<small>SHORTS</small>
</div>
</div>
</section>
<section className="about-section">
<h2>{copy.whoTitle}</h2>
<p className="about-lead">{copy.whoText}</p>
</section>
<CompanySection copy={copy} />
<section className="about-section">
<h2>{copy.pillarsTitle}</h2>
<div className="about-pillars">
{PILLARS.map(({ key, icon: Icon }) => (
<article className="about-pillar" key={key}>
<span className="about-pillar-icon"><Icon size={22} /></span>
<h3>{copy[`${key}Title`]}</h3>
<p>{copy[`${key}Text`]}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<h2>{copy.roadmapTitle}</h2>
<ol className="about-roadmap">
{copy.roadmap.map((step, index) => (
<li key={step.title}>
<span className="about-step-index">{String(index + 1).padStart(2, '0')}</span>
<div>
<b>{step.year} · {step.title}</b>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</section>
<section className="about-cta">
<div>
<h2>{copy.ctaTitle}</h2>
<p>{copy.ctaText}</p>
</div>
<Link to="/shorts" className="about-primary">{copy.ctaButton}<ArrowRight size={16} /></Link>
</section>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
// Author: fruit.com.cn
export const aboutContent = {
en: {
kicker: 'OFFICIAL YMB SITE',
heroTitle: 'Short stories. Big business.',
heroText: 'YMB (Your Mini Business) is the official home of the YMB micro-drama app — a mobile-first stage for bite-sized stories, and an open platform for the next generation of creators.',
explore: 'Explore dramas',
watchShorts: 'Watch Shorts',
downloadLabel: 'Get the YMB app',
iosSmall: 'Download on the',
androidSmall: 'Get it on',
values: ['Mobile-first', 'Multilingual', 'Open platform'],
whoTitle: 'Who we are',
whoText: 'YMB brings vertical, fast-paced dramas to audiences around the world. We localize stories across languages, connect them with the right viewers, and keep the experience light, mobile-first, and free to explore.',
companyTitle: 'Company information',
companyLegalName: 'Legal entity',
companyAddress: 'Registered address',
companyRegistration: 'Business registration',
companyEmail: 'Contact email',
companyPhone: 'Contact phone',
pillarsTitle: 'Where YMB is going',
joinTitle: 'Creator onboarding',
joinText: 'Studios and independent creators will be able to join YMB, publish their work, and grow with the platform.',
produceTitle: 'Self-production',
produceText: 'Original YMB productions get the tools, distribution, and audience to turn a good story into a hit series.',
computeTitle: 'Computing power',
computeText: 'A future marketplace for rendering and AI compute — buy, sell, and allocate capacity for production.',
roadmapTitle: 'Roadmap',
roadmap: [
{ year: '2026', title: 'App launch', text: 'The YMB app goes live with a multilingual micro-drama library and a swipe-first Shorts feed.' },
{ year: 'Next', title: 'Creator onboarding', text: 'Open registration for creators and studios, with publishing and audience tools.' },
{ year: 'Next', title: 'Self-production', text: 'YMB Originals and partner productions, supported from script to screen.' },
{ year: 'Next', title: 'Compute marketplace', text: 'Sell or allocate rendering and AI compute directly inside the YMB ecosystem.' },
],
ctaTitle: 'Build the next hit with YMB.',
ctaText: 'Whether you create, produce, or power the next story — there is a place for you on YMB.',
ctaButton: 'Start exploring',
},
id: {
kicker: 'SITUS RESMI YMB',
heroTitle: 'Cerita pendek. Bisnis besar.',
heroText: 'YMB (Your Mini Business) adalah rumah resmi aplikasi drama pendek YMB — panggung mobile-first untuk cerita singkat, sekaligus platform terbuka bagi generasi kreator berikutnya.',
explore: 'Jelajahi drama',
watchShorts: 'Tonton Shorts',
downloadLabel: 'Unduh aplikasi YMB',
iosSmall: 'Unduh di',
androidSmall: 'Dapatkan di',
values: ['Mobile-first', 'Multibahasa', 'Platform terbuka'],
whoTitle: 'Siapa kami',
whoText: 'YMB menghadirkan drama vertikal yang cepat ke penonton di seluruh dunia. Kami melokalkan cerita, menghubungkannya dengan penonton yang tepat, dan menjaga pengalaman tetap ringan, mobile-first, dan gratis untuk dijelajahi.',
companyTitle: 'Informasi perusahaan',
companyLegalName: 'Badan hukum',
companyAddress: 'Alamat terdaftar',
companyRegistration: 'Registrasi bisnis',
companyEmail: 'Email kontak',
companyPhone: 'Telepon kontak',
pillarsTitle: 'Arah YMB berikutnya',
joinTitle: 'Bergabung kreator',
joinText: 'Studio dan kreator independen akan dapat bergabung dengan YMB, mempublikasikan karya, dan tumbuh bersama platform.',
produceTitle: 'Produksi mandiri',
produceText: 'Produksi orisinal YMB mendapatkan alat, distribusi, dan audiens untuk mengubah cerita bagus menjadi serial hit.',
computeTitle: 'Daya komputasi',
computeText: 'Marketplace masa depan untuk rendering dan komputasi AI — beli, jual, dan alokasikan kapasitas produksi.',
roadmapTitle: 'Peta jalan',
roadmap: [
{ year: '2026', title: 'Peluncuran app', text: 'Aplikasi YMB hadir dengan pustaka drama multibahasa dan feed Shorts geser.' },
{ year: 'Berikutnya', title: 'Bergabung kreator', text: 'Pendaftaran terbuka untuk kreator dan studio, dengan alat publikasi dan audiens.' },
{ year: 'Berikutnya', title: 'Produksi mandiri', text: 'Produksi YMB Originals dan mitra, didukung dari naskah hingga layar.' },
{ year: 'Berikutnya', title: 'Marketplace komputasi', text: 'Jual atau alokasikan rendering dan komputasi AI langsung di ekosistem YMB.' },
],
ctaTitle: 'Bangun hit berikutnya bersama YMB.',
ctaText: 'Baik kamu kreator, produser, atau mitra komputasi — ada tempat untukmu di YMB.',
ctaButton: 'Mulai jelajahi',
},
zh: {
kicker: 'YMB 官方网站',
heroTitle: '短故事,大生意。',
heroText: 'YMB(Your Mini Business)微短剧 App 官网 —— 一个为竖屏短故事而生的移动舞台,也是一个向新一代创作者开放的平台。',
explore: '浏览短剧',
watchShorts: '观看 Shorts',
downloadLabel: '下载 YMB App',
iosSmall: '前往下载',
androidSmall: '前往获取',
values: ['移动优先', '多语言', '开放平台'],
whoTitle: '我们是谁',
whoText: 'YMB 把节奏明快、适合竖屏观看的短剧带给全球观众。我们做多语言本地化、内容分发与轻量化的移动观影体验,让好故事被更多人看到。',
companyTitle: '公司信息',
companyLegalName: '法定主体',
companyAddress: '注册地址',
companyRegistration: '商业登记',
companyEmail: '联系邮箱',
companyPhone: '联系电话',
pillarsTitle: 'YMB 的下一步',
joinTitle: '创作者入驻',
joinText: '影视公司与独立创作者将可以入驻 YMB,发布作品,并与平台一起成长。',
produceTitle: '自主制片',
produceText: 'YMB 自制内容将获得工具、分发与观众资源,把好故事做成爆款短剧。',
computeTitle: '算力出售',
computeText: '面向渲染与 AI 算力的未来市场,在 YMB 生态内买卖与调配算力。',
roadmapTitle: '发展路线',
roadmap: [
{ year: '2026', title: 'App 上线', text: 'YMB App 正式上线,提供多语言短剧库与竖滑 Shorts 信息流。' },
{ year: '下一步', title: '创作者入驻', text: '面向创作者与影视公司开放注册,提供发布与受众运营工具。' },
{ year: '下一步', title: '自主制片', text: 'YMB 自制与联合制片项目,从剧本到成片全程支持。' },
{ year: '下一步', title: '算力市场', text: '在 YMB 生态内直接出售或调配渲染与 AI 算力。' },
],
ctaTitle: '和 YMB 一起打造下一个爆款。',
ctaText: '无论你是创作者、制片方还是算力伙伴,YMB 都有你的位置。',
ctaButton: '开始探索',
},
};
+65
View File
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { HistoryGridSkeleton } from '../../shared/components/skeletons';
import { useMutation } from '../../shared/hooks/useMutation';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { watchApi } from '../../shared/api/watch';
import { normalizeProgress } from '../../entities/drama/model';
function episodeLabel(record, localeCode) {
if (!record.episodeNumber) return '';
return localeCode === 'zh' ? `第${record.episodeNumber}集` : `Episode ${record.episodeNumber}`;
}
export function HistoryPage() {
const { locale } = useLocale();
const history = useRequest(
({ signal }) => watchApi.recent(1, 20, { signal }).then((payload) => normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code))),
[locale.code],
);
const clearHistory = useMutation(() => watchApi.clear());
const records = history.data;
const errorMessage = history.error?.message || clearHistory.error?.message || '';
const clearAll = async () => {
if (!window.confirm(`${locale.clearHistory}?`)) return;
try {
await clearHistory.run();
history.refetch();
} catch {
// 错误由 clearHistory.error 呈现
}
};
return (
<section className="history-page">
<div className="history-heading">
<h1>{locale.history}</h1>
{!!records?.length && <button type="button" onClick={clearAll}>{locale.clearHistory}</button>}
</div>
{history.loading && records === null && <HistoryGridSkeleton count={6} label={locale.loading} />}
{errorMessage && <div className="api-note warning">{errorMessage}</div>}
{records && !records.length && !errorMessage && <div className="api-note">{locale.historyEmpty}</div>}
{!!records?.length && (
<div className="video-grid">
{records.map((record) => {
const percent = Math.round(record.fraction * 100);
const meta = [episodeLabel(record, locale.code), record.durationMs > 0 ? `${percent}%` : ''].filter(Boolean).join(' · ');
return (
<article className="history-card" key={record.id}>
<Link to={`/watch/${record.id}`} className="history-thumb">
<img src={record.image} alt={record.title} loading="lazy" />
{record.durationMs > 0 && <span className="history-progress"><i style={{ width: `${percent}%` }} /></span>}
</Link>
<Link to={`/watch/${record.id}`} className="history-title">{record.title}</Link>
{meta && <small>{meta}</small>}
</article>
);
})}
</div>
)}
</section>
);
}
+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import { useMemo, useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
import { AsyncBoundary } from '../../shared/components/AsyncBoundary';
import { MediaCard } from '../../shared/components/MediaCard';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../../shared/components/skeletons';
import { videos } from '../../shared/data/catalog';
import { searchCatalog } from '../../shared/data/catalogService';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { categoryApi } from '../../entities/category/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { CategoryBar } from './components/CategoryBar';
const SHORTS_LIMIT = 5;
const FIRST_SHELF_LIMIT = 6;
const ALL_CATEGORY_ID = 'all';
const FALLBACK_GENRE_PREFIX = 'genre-';
function readTitle(locales, localeCode) {
return locales?.[localeCode] || locales?.en || locales?.id || locales?.zh || '';
}
function normalizeHomeItems(payload, localeCode) {
const sections = payload?.sections || [];
return uniqueDramas(sections.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategoryItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategories(payload, localeCode) {
return (Array.isArray(payload) ? payload : [])
.map((item) => ({
id: String(item.id ?? ''),
label: readTitle(item.locales, localeCode) || item.code || '',
}))
.filter((item) => item.id && item.label);
}
function fallbackGenreIndex(categoryId) {
const matched = /^genre-(\d+)$/.exec(categoryId);
return matched ? Number(matched[1]) : 0;
}
export function HomePage() {
const { locale } = useLocale();
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID);
const isAllCategory = activeCategory === ALL_CATEGORY_ID;
const feedRequest = useRequest(
({ signal }) => dramaApi.homeFeed({ signal }).then((payload) => normalizeHomeItems(payload, locale.code)),
[locale.code],
);
const categoriesRequest = useRequest(
({ signal }) => categoryApi.list({ signal }).then((payload) => normalizeCategories(payload, locale.code)),
[locale.code],
);
const categoryRequest = useRequest(
({ signal }) => categoryApi.dramas(activeCategory, 1, 20, { signal }).then((payload) => normalizeCategoryItems(payload, locale.code)),
[activeCategory, locale.code],
{ enabled: !isAllCategory },
);
const homeItems = feedRequest.data || [];
const serverCategories = useMemo(() => categoriesRequest.data || [], [categoriesRequest.data]);
const fallbackCategories = useMemo(
() => locale.genres.slice(1).map((label, index) => ({ id: `${FALLBACK_GENRE_PREFIX}${index + 1}`, label })),
[locale.genres],
);
const categories = useMemo(() => {
const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] };
if (serverCategories.length) return [allCategory, ...serverCategories];
return categoriesRequest.loading ? [allCategory] : [allCategory, ...fallbackCategories];
}, [categoriesRequest.loading, fallbackCategories, locale.genres, serverCategories]);
const fallbackItems = useMemo(() => {
const genreIndex = fallbackGenreIndex(activeCategory);
return searchCatalog('', locale.genres[genreIndex], genreIndex);
}, [activeCategory, locale.genres]);
const loading = isAllCategory ? feedRequest.loading : categoryRequest.loading;
const error = isAllCategory ? feedRequest.error : categoryRequest.error;
const remoteItems = isAllCategory ? homeItems : (categoryRequest.data || []);
const items = error ? fallbackItems : remoteItems;
const shortsItems = (homeItems.length ? homeItems : videos).slice(0, SHORTS_LIMIT);
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
return (
<>
{categoriesRequest.loading && !serverCategories.length ? <CategoryChipsSkeleton /> : (
<CategoryBar categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
)}
<section className="youtube-home-feed" aria-label={locale.home}>
{error && <div className="api-note warning">{locale.apiFallback}</div>}
<AsyncBoundary
loading={loading && !items.length}
skeleton={<VideoGridSkeleton count={FIRST_SHELF_LIMIT} label={locale.loading} />}
empty={!loading && !error && !items.length}
emptyMessage={locale.empty}
>
<VideoGrid items={firstShelf} />
</AsyncBoundary>
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
<div className="youtube-shelf-heading">
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
<button type="button">{locale.explore}</button>
</div>
{feedRequest.loading && !homeItems.length ? <ShortsShelfSkeleton count={SHORTS_LIMIT} label={locale.loading} /> : (
<div className="shorts-row" aria-label={locale.shortTitle}>
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact to={`/shorts?start=${video.id}`} />)}
</div>
)}
</section>
{secondShelf.length > 0 && (
<section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
<VideoGrid items={secondShelf} />
</section>
)}
</>
);
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
export function CategoryBar({ categories, activeId, onChange }) {
return (
<div className="youtube-category-bar" role="tablist" aria-label="Categories">
{categories.map((category) => (
<button
className={`youtube-category-chip ${activeId === category.id ? 'active' : ''}`}
key={category.id}
type="button"
role="tab"
aria-selected={activeId === category.id}
onClick={() => onChange(category.id)}
>
{category.label}
</button>
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
// Author: fruit.com.cn
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { COMPANY } from '../../shared/data/company';
import { legalContent } from './legalContent';
function LegalSection({ section, index }) {
return (
<section className="legal-section">
<h2>{index + 1}. {section.title}</h2>
{section.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
{section.bullets && (
<ul>{section.bullets.map((bullet) => <li key={bullet}>{bullet}</li>)}</ul>
)}
</section>
);
}
export function LegalPage({ type }) {
const { locale } = useLocale();
const localized = legalContent[type];
const copy = localized[locale.code] || localized.en;
useEffect(() => {
document.title = `${copy.title} · YMB`;
}, [copy.title]);
return (
<article className="legal-page">
<header className="legal-header">
<span className="legal-kicker">YMB · LEGAL</span>
<h1>{copy.title}</h1>
<p className="legal-effective">{copy.effective}</p>
<p className="legal-summary">{copy.summary}</p>
<nav className="legal-nav" aria-label="Legal documents">
<Link className={type === 'privacy' ? 'active' : ''} to="/privacy">{copy.privacyLabel}</Link>
<Link className={type === 'terms' ? 'active' : ''} to="/terms">{copy.termsLabel}</Link>
</nav>
</header>
<div className="legal-body">
{copy.sections.map((section, index) => (
<LegalSection key={section.title} section={section} index={index} />
))}
</div>
<footer className="legal-contact">
<h2>{copy.contactTitle}</h2>
<p><strong>{COMPANY.legalName}</strong><br />{COMPANY.address}</p>
<p>
<a href={`mailto:${COMPANY.email}`}>{COMPANY.email}</a><br />
<a href={`tel:${COMPANY.phoneHref}`}>{COMPANY.phone}</a>
</p>
</footer>
</article>
);
}
+50
View File
@@ -0,0 +1,50 @@
/* Author: fruit.com.cn */
.company-card {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin-top: 24px;
border: 1px solid #ffffff12;
border-radius: 20px;
background: #ffffff12;
}
.company-item { padding: 24px; background: #121318; }
.company-item:first-child { grid-column: 1 / -1; background: linear-gradient(120deg, #17243b, #121318); }
.company-item span { display: block; margin-bottom: 8px; color: #8f96a5; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.company-item strong, .company-item a { color: #eef2fa; font-size: 15px; font-weight: 600; line-height: 1.7; overflow-wrap: anywhere; }
.company-item a:hover { color: #a9ccff; }
.legal-page { max-width: 880px; margin: 0 auto; padding: 24px 0 56px; }
.legal-header { padding: 44px; border: 1px solid #ffffff10; border-radius: 26px; background: radial-gradient(120% 160% at 0% 0%, #172943, #111217 58%, #251018); }
.legal-kicker { color: #9ecbff; font-size: 11px; font-weight: 700; letter-spacing: .16em; }
.legal-header h1 { margin: 16px 0 10px; color: #fff; font-size: clamp(34px, 5vw, 52px); line-height: 1.1; letter-spacing: -.035em; }
.legal-effective { margin: 0 0 22px; color: #8f96a5; font-size: 13px; }
.legal-summary { max-width: 730px; margin: 0; color: #c2c7d1; font-size: 15px; line-height: 1.85; }
.legal-nav { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
.legal-nav a { padding: 9px 15px; border-radius: 999px; background: #ffffff0d; color: #c7ccd6; font-size: 13px; }
.legal-nav a:hover, .legal-nav a.active { background: #f1f5ff; color: #111318; }
.legal-body { margin-top: 22px; padding: 12px 44px; border: 1px solid #ffffff0d; border-radius: 24px; background: #111216; }
.legal-section { margin: 0; padding: 28px 0; border-bottom: 1px solid #ffffff0d; }
.legal-section:last-child { border-bottom: 0; }
.legal-section h2, .legal-contact h2 { margin: 0 0 14px; color: #f5f7fb; font-size: 19px; line-height: 1.45; }
.legal-section p, .legal-section li, .legal-contact p { color: #b4bac5; font-size: 14px; line-height: 1.85; }
.legal-section p { margin: 10px 0 0; }
.legal-section ul { margin: 12px 0 0; padding-left: 22px; }
.legal-section li { margin: 7px 0; padding-left: 4px; }
.legal-contact { margin-top: 22px; padding: 30px 44px; border-radius: 22px; background: #171a22; }
.legal-contact p { margin: 8px 0; overflow-wrap: anywhere; }
.legal-contact a { color: #a9ccff; }
.site-footer-links { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; margin-top: 10px; }
.site-footer-links a { color: #a0a0a0; }
.site-footer-links a:hover { color: #fff; text-decoration: underline; }
@media (max-width: 640px) {
.company-card { grid-template-columns: 1fr; }
.company-item:first-child { grid-column: auto; }
.company-item { padding: 20px; }
.legal-page { padding-top: 4px; }
.legal-header { padding: 28px 22px; border-radius: 20px; }
.legal-body { padding: 6px 22px; }
.legal-section { padding: 22px 0; }
.legal-contact { padding: 24px 22px; }
}
+142
View File
@@ -0,0 +1,142 @@
// Author: fruit.com.cn
import { COMPANY } from '../../shared/data/company';
const labels = {
en: { privacyLabel: 'Privacy Policy', termsLabel: 'Terms of Service', contactTitle: 'Contact us' },
id: { privacyLabel: 'Kebijakan Privasi', termsLabel: 'Ketentuan Layanan', contactTitle: 'Hubungi kami' },
};
export const legalContent = {
privacy: {
en: {
...labels.en,
title: 'Privacy Policy',
effective: 'Effective date: November 13, 2026',
summary: `This Privacy Policy explains how ${COMPANY.legalName} (“YMB”, “we”, “us”) collects, uses, discloses, retains, and protects personal data when you use the YMB website, mobile applications, and related services (collectively, the “Service”).`,
sections: [
{ title: 'Data we collect', paragraphs: ['We collect only the data needed to provide, secure, and improve the Service. The data varies depending on the features you use.'], bullets: [
'Account data: name, email address, phone number, password credentials where applicable, profile information, and account identifiers.',
'Google Sign-In data: your Google account identifier, name, email address, and profile image when you choose to sign in with Google. We do not receive your Google password.',
'Usage data: watch history, viewing progress, searches, likes, comments, follows, device interactions, and approximate session information.',
'Transaction data: Google Play product identifiers, purchase tokens, order status, coin balance changes, subscription status, and support records. Google processes your payment method; we do not store your full card number.',
'Device and diagnostic data: device model, operating system, app version, language, IP address, log records, crash reports, and approximate region derived from IP.',
'Content and communications: videos, comments, reports, requests, and other material you submit, including messages sent to customer support.',
] },
{ title: 'How we use personal data', bullets: [
'Create and administer your account and authenticate your sign-in.',
'Provide video playback, continue-watching, recommendations, search, social features, and creator functions.',
'Verify Google Play purchases, deliver coins or subscriptions, prevent duplicate fulfilment, handle refunds, and reconcile orders.',
'Maintain security, prevent fraud and abuse, debug errors, measure performance, and improve content and features.',
'Respond to support requests, send service notices, and comply with legal obligations.',
] },
{ title: 'Legal bases', paragraphs: ['Where applicable, we process personal data based on your consent, the performance of our contract with you, our legitimate interests in operating and securing the Service, and compliance with legal obligations. You may withdraw consent for future processing, subject to legal and contractual restrictions.'] },
{ title: 'When we share data', paragraphs: ['We do not sell your personal data. We may share limited data with:'], bullets: [
'Google for Google Sign-In, Google Play distribution, billing, purchase verification, and related platform services.',
'Hosting, cloud storage, content delivery, analytics, security, and customer-support providers acting on our instructions.',
'Professional advisers, regulators, courts, or law-enforcement bodies where disclosure is legally required or necessary to protect rights and safety.',
'A successor in a merger, financing, reorganisation, or asset transfer, subject to appropriate confidentiality and data-protection safeguards.',
] },
{ title: 'International processing', paragraphs: ['Service providers may process data outside Indonesia. Where we transfer personal data internationally, we use safeguards required by applicable law and take reasonable steps to ensure an adequate level of protection.'] },
{ title: 'Retention', paragraphs: ['We retain personal data only for as long as needed for the purposes described above, including account operation, transaction records, dispute resolution, security, and legal compliance. Retention periods vary by data type. When data is no longer required, we delete, anonymise, or securely isolate it.'] },
{ title: 'Security', paragraphs: ['We use reasonable administrative, technical, and organisational safeguards, including access controls and encrypted transport where supported. No system is completely secure; please protect your credentials and notify us of suspected unauthorised access.'] },
{ title: 'Your rights', paragraphs: ['Subject to Indonesian Law No. 27 of 2022 on Personal Data Protection and other applicable law, you may request access, correction, deletion, restriction, withdrawal of consent, objection, or portability of your personal data. We may verify your identity before responding and may retain records that the law requires or permits.'] },
{ title: 'Account and data deletion', paragraphs: [`To request deletion of your YMB account and associated personal data, email ${COMPANY.email} from your registered email or contact us through the Service. Include enough information for us to identify the account, but never send your password. We will verify the request and delete or anonymise data unless retention is required for transactions, fraud prevention, disputes, or legal obligations. Uninstalling the app alone does not delete your account.`] },
{ title: 'Children', paragraphs: ['The Service is not directed to children under 13. Where local law requires a higher age for independent consent, a parent or guardian must provide consent. If we learn that we collected a child’s data without required consent, we will take reasonable steps to delete it.'] },
{ title: 'Third-party services', paragraphs: ['Google and other third-party services process data under their own privacy policies. You can manage your Google account permissions through your Google account settings. Links to third-party sites do not imply responsibility for their practices.'] },
{ title: 'Changes to this policy', paragraphs: ['We may update this policy to reflect changes in the Service or legal requirements. We will publish the revised policy with a new effective date and provide additional notice where required. Continued use after the effective date is subject to the revised policy.'] },
],
},
id: {
...labels.id,
title: 'Kebijakan Privasi',
effective: 'Tanggal berlaku: 13 November 2026',
summary: `Kebijakan Privasi ini menjelaskan cara ${COMPANY.legalName} (“YMB”, “kami”) mengumpulkan, menggunakan, mengungkapkan, menyimpan, dan melindungi data pribadi saat Anda menggunakan situs web, aplikasi seluler, dan layanan terkait YMB (secara bersama-sama disebut “Layanan”).`,
sections: [
{ title: 'Data yang kami kumpulkan', paragraphs: ['Kami hanya mengumpulkan data yang diperlukan untuk menyediakan, mengamankan, dan meningkatkan Layanan. Jenis data bergantung pada fitur yang Anda gunakan.'], bullets: [
'Data akun: nama, alamat email, nomor telepon, kredensial kata sandi jika berlaku, informasi profil, dan identifikasi akun.',
'Data Login dengan Google: ID akun Google, nama, alamat email, dan foto profil saat Anda memilih Login dengan Google. Kami tidak menerima kata sandi Google Anda.',
'Data penggunaan: riwayat tontonan, progres menonton, pencarian, suka, komentar, mengikuti, interaksi perangkat, dan informasi sesi.',
'Data transaksi: ID produk Google Play, token pembelian, status pesanan, perubahan saldo koin, status langganan, dan catatan dukungan. Google memproses metode pembayaran Anda; kami tidak menyimpan nomor kartu lengkap.',
'Data perangkat dan diagnostik: model perangkat, sistem operasi, versi aplikasi, bahasa, alamat IP, catatan log, laporan kerusakan, dan perkiraan wilayah berdasarkan IP.',
'Konten dan komunikasi: video, komentar, laporan, permintaan, dan materi lain yang Anda kirim, termasuk pesan kepada layanan pelanggan.',
] },
{ title: 'Cara kami menggunakan data pribadi', bullets: [
'Membuat dan mengelola akun serta mengautentikasi proses masuk.',
'Menyediakan pemutaran video, lanjut menonton, rekomendasi, pencarian, fitur sosial, dan fungsi kreator.',
'Memverifikasi pembelian Google Play, memberikan koin atau langganan, mencegah pemenuhan ganda, menangani pengembalian dana, dan merekonsiliasi pesanan.',
'Menjaga keamanan, mencegah penipuan dan penyalahgunaan, memperbaiki kesalahan, mengukur kinerja, serta meningkatkan konten dan fitur.',
'Menjawab permintaan dukungan, mengirim pemberitahuan layanan, dan memenuhi kewajiban hukum.',
] },
{ title: 'Dasar pemrosesan', paragraphs: ['Jika berlaku, kami memproses data pribadi berdasarkan persetujuan Anda, pelaksanaan kontrak dengan Anda, kepentingan sah kami dalam mengoperasikan dan mengamankan Layanan, serta kepatuhan terhadap kewajiban hukum. Anda dapat menarik persetujuan untuk pemrosesan berikutnya, dengan tunduk pada batasan hukum dan kontrak.'] },
{ title: 'Kapan kami membagikan data', paragraphs: ['Kami tidak menjual data pribadi Anda. Kami dapat membagikan data terbatas kepada:'], bullets: [
'Google untuk Login dengan Google, distribusi Google Play, penagihan, verifikasi pembelian, dan layanan platform terkait.',
'Penyedia hosting, penyimpanan cloud, jaringan pengiriman konten, analitik, keamanan, dan dukungan pelanggan yang bertindak atas instruksi kami.',
'Penasihat profesional, regulator, pengadilan, atau aparat penegak hukum jika diwajibkan oleh hukum atau diperlukan untuk melindungi hak dan keselamatan.',
'Penerus dalam merger, pembiayaan, reorganisasi, atau pengalihan aset, dengan perlindungan kerahasiaan dan data yang sesuai.',
] },
{ title: 'Pemrosesan internasional', paragraphs: ['Penyedia layanan dapat memproses data di luar Indonesia. Jika kami mentransfer data pribadi secara internasional, kami menggunakan perlindungan yang diwajibkan oleh hukum dan mengambil langkah wajar untuk memastikan tingkat perlindungan yang memadai.'] },
{ title: 'Penyimpanan data', paragraphs: ['Kami menyimpan data pribadi hanya selama diperlukan untuk tujuan di atas, termasuk pengoperasian akun, catatan transaksi, penyelesaian sengketa, keamanan, dan kepatuhan hukum. Jangka waktu berbeda menurut jenis data. Jika tidak lagi diperlukan, data akan dihapus, dianonimkan, atau diisolasi secara aman.'] },
{ title: 'Keamanan', paragraphs: ['Kami menggunakan perlindungan administratif, teknis, dan organisasi yang wajar, termasuk kontrol akses dan transmisi terenkripsi jika didukung. Tidak ada sistem yang sepenuhnya aman; lindungi kredensial Anda dan beri tahu kami jika ada dugaan akses tanpa izin.'] },
{ title: 'Hak Anda', paragraphs: ['Dengan tunduk pada Undang-Undang Republik Indonesia Nomor 27 Tahun 2022 tentang Pelindungan Data Pribadi dan hukum lain yang berlaku, Anda dapat meminta akses, koreksi, penghapusan, pembatasan, penarikan persetujuan, keberatan, atau portabilitas data pribadi. Kami dapat memverifikasi identitas Anda dan menyimpan catatan yang diwajibkan atau diizinkan oleh hukum.'] },
{ title: 'Penghapusan akun dan data', paragraphs: [`Untuk meminta penghapusan akun YMB dan data pribadi terkait, kirim email ke ${COMPANY.email} dari email terdaftar atau hubungi kami melalui Layanan. Berikan informasi yang cukup untuk mengidentifikasi akun, tetapi jangan pernah mengirim kata sandi. Kami akan memverifikasi permintaan dan menghapus atau menganonimkan data kecuali penyimpanan diperlukan untuk transaksi, pencegahan penipuan, sengketa, atau kewajiban hukum. Menghapus instalasi aplikasi saja tidak menghapus akun Anda.`] },
{ title: 'Anak-anak', paragraphs: ['Layanan tidak ditujukan kepada anak di bawah 13 tahun. Jika hukum setempat menetapkan usia lebih tinggi untuk persetujuan mandiri, persetujuan orang tua atau wali diperlukan. Jika kami mengetahui data anak dikumpulkan tanpa persetujuan yang diwajibkan, kami akan mengambil langkah wajar untuk menghapusnya.'] },
{ title: 'Layanan pihak ketiga', paragraphs: ['Google dan layanan pihak ketiga lain memproses data berdasarkan kebijakan privasi mereka. Anda dapat mengelola izin akun Google melalui pengaturan akun Google. Tautan ke situs pihak ketiga tidak berarti kami bertanggung jawab atas praktik mereka.'] },
{ title: 'Perubahan kebijakan', paragraphs: ['Kami dapat memperbarui kebijakan ini untuk mencerminkan perubahan Layanan atau persyaratan hukum. Kebijakan revisi akan diterbitkan dengan tanggal berlaku baru dan pemberitahuan tambahan jika diwajibkan. Penggunaan berkelanjutan setelah tanggal berlaku tunduk pada kebijakan revisi.'] },
],
},
},
terms: {
en: {
...labels.en,
title: 'Terms of Service',
effective: 'Effective date: November 13, 2026',
summary: `These Terms of Service govern your access to and use of the YMB Service provided by ${COMPANY.legalName}. By creating an account or using the Service, you agree to these Terms.`,
sections: [
{ title: 'Eligibility and accounts', paragraphs: ['You must be legally capable of agreeing to these Terms or use the Service with the consent and supervision of a parent or guardian. Provide accurate information, protect your credentials, and notify us of unauthorised activity. You are responsible for activity under your account.'] },
{ title: 'The Service', paragraphs: ['YMB provides mobile-first short dramas, related web features, and creator or social functions that may change over time. Content availability can vary by region, language, device, and licensing rights. We may add, remove, or modify features to operate and improve the Service.'] },
{ title: 'Google Sign-In and third-party platforms', paragraphs: ['If you choose Google Sign-In, your use of Google is also governed by Google’s terms and privacy policy. The Service is distributed through platforms such as Google Play, whose terms apply to downloads, payments, subscriptions, and refunds.'] },
{ title: 'Virtual coins and one-time purchases', paragraphs: ['Virtual coins are limited, non-transferable digital credits used only to access eligible content or features within YMB. They are not money, have no cash value, cannot be redeemed for cash, and must not be traded outside the Service. Prices and applicable taxes are shown before purchase. Except where required by law or approved under the applicable Google Play refund policy, purchases are final and non-refundable.'] },
{ title: 'Subscriptions', paragraphs: ['If a subscription is offered, its price, billing period, benefits, and renewal terms are displayed before purchase. Unless cancelled, subscriptions renew through Google Play. You can manage or cancel a subscription in your Google Play account. Cancellation stops future renewals; access continues until the end of the paid period unless otherwise stated.'] },
{ title: 'Your content', paragraphs: ['You retain ownership of content you submit. You grant YMB a worldwide, non-exclusive, royalty-free licence to host, store, reproduce, transcode, distribute, publicly perform, display, and promote that content solely to operate and promote the Service. You confirm that you have all necessary rights and that your content does not infringe the rights of others.'] },
{ title: 'Acceptable use', paragraphs: ['You must not:'], bullets: [
'Upload unlawful, infringing, fraudulent, hateful, sexually exploitative, or harmful content.',
'Attempt unauthorised access, interfere with security, scrape at disruptive volume, reverse engineer except where legally permitted, or distribute malware.',
'Manipulate purchases, coins, subscriptions, metrics, recommendations, or payment verification.',
'Harass others, impersonate a person or entity, or use the Service for misleading commercial spam.',
] },
{ title: 'Intellectual property', paragraphs: ['The Service, its software, design, branding, and licensed content are owned by YMB or its licensors and protected by intellectual-property laws. Except for the limited right to use the Service under these Terms, no ownership or additional licence is transferred to you.'] },
{ title: 'Content enforcement and termination', paragraphs: ['We may remove content, restrict features, suspend, or terminate access if we reasonably believe you violated these Terms, created legal or security risk, or harmed other users or the Service. You may stop using the Service at any time. Provisions that by nature should survive termination remain effective.'] },
{ title: 'Disclaimers', paragraphs: ['To the extent permitted by law, the Service is provided on an “as available” basis. We do not guarantee uninterrupted availability, error-free operation, or that every title will remain available. Nothing in these Terms excludes consumer rights that cannot legally be excluded.'] },
{ title: 'Limitation of liability', paragraphs: ['To the extent permitted by law, YMB is not liable for indirect, incidental, special, or consequential loss arising from use of the Service. Any statutory rights or liabilities that cannot be limited remain unaffected.'] },
{ title: 'Governing law', paragraphs: ['These Terms are governed by the laws of the Republic of Indonesia, without regard to conflict-of-law principles. Courts or dispute-resolution bodies with jurisdiction under applicable Indonesian law will handle disputes, subject to mandatory consumer protections.'] },
{ title: 'Changes and contact', paragraphs: ['We may revise these Terms and will publish the updated version with a new effective date. Material changes will receive additional notice where required. Questions may be sent using the contact details below.'] },
],
},
id: {
...labels.id,
title: 'Ketentuan Layanan',
effective: 'Tanggal berlaku: 13 November 2026',
summary: `Ketentuan Layanan ini mengatur akses dan penggunaan Layanan YMB yang disediakan oleh ${COMPANY.legalName}. Dengan membuat akun atau menggunakan Layanan, Anda menyetujui Ketentuan ini.`,
sections: [
{ title: 'Kelayakan dan akun', paragraphs: ['Anda harus secara hukum mampu menyetujui Ketentuan ini atau menggunakan Layanan dengan persetujuan dan pengawasan orang tua atau wali. Berikan informasi yang akurat, lindungi kredensial, dan beri tahu kami tentang aktivitas tanpa izin. Anda bertanggung jawab atas aktivitas dalam akun Anda.'] },
{ title: 'Layanan', paragraphs: ['YMB menyediakan drama pendek mobile-first, fitur web terkait, serta fungsi kreator atau sosial yang dapat berubah. Ketersediaan konten dapat berbeda menurut wilayah, bahasa, perangkat, dan hak lisensi. Kami dapat menambah, menghapus, atau mengubah fitur untuk mengoperasikan dan meningkatkan Layanan.'] },
{ title: 'Login dengan Google dan platform pihak ketiga', paragraphs: ['Jika Anda memilih Login dengan Google, penggunaan Google juga tunduk pada ketentuan dan kebijakan privasi Google. Layanan didistribusikan melalui platform seperti Google Play, yang ketentuannya berlaku untuk unduhan, pembayaran, langganan, dan pengembalian dana.'] },
{ title: 'Koin virtual dan pembelian satu kali', paragraphs: ['Koin virtual adalah kredit digital terbatas dan tidak dapat dipindahtangankan yang hanya digunakan untuk mengakses konten atau fitur yang memenuhi syarat dalam YMB. Koin bukan uang, tidak memiliki nilai tunai, tidak dapat ditukar dengan uang tunai, dan tidak boleh diperdagangkan di luar Layanan. Harga dan pajak yang berlaku ditampilkan sebelum pembelian. Kecuali diwajibkan oleh hukum atau disetujui berdasarkan kebijakan pengembalian dana Google Play yang berlaku, pembelian bersifat final dan tidak dapat dikembalikan.'] },
{ title: 'Langganan', paragraphs: ['Jika langganan ditawarkan, harga, periode penagihan, manfaat, dan ketentuan perpanjangan ditampilkan sebelum pembelian. Kecuali dibatalkan, langganan diperpanjang melalui Google Play. Anda dapat mengelola atau membatalkan langganan di akun Google Play. Pembatalan menghentikan perpanjangan berikutnya; akses berlanjut hingga akhir periode yang telah dibayar kecuali dinyatakan lain.'] },
{ title: 'Konten Anda', paragraphs: ['Anda tetap memiliki konten yang Anda kirim. Anda memberi YMB lisensi worldwide, non-eksklusif, dan bebas royalti untuk menghosting, menyimpan, memperbanyak, mentranskode, mendistribusikan, mempertunjukkan, menampilkan, dan mempromosikan konten tersebut semata-mata untuk mengoperasikan dan mempromosikan Layanan. Anda menjamin memiliki semua hak yang diperlukan dan konten tidak melanggar hak pihak lain.'] },
{ title: 'Penggunaan yang diperbolehkan', paragraphs: ['Anda tidak boleh:'], bullets: [
'Mengunggah konten yang melanggar hukum, melanggar hak, menipu, berisi kebencian, eksploitatif secara seksual, atau berbahaya.',
'Mencoba akses tanpa izin, mengganggu keamanan, melakukan scraping dalam volume yang mengganggu, merekayasa balik kecuali diizinkan hukum, atau menyebarkan malware.',
'Memanipulasi pembelian, koin, langganan, metrik, rekomendasi, atau verifikasi pembayaran.',
'Melecehkan orang lain, menyamar sebagai orang atau entitas lain, atau menggunakan Layanan untuk spam komersial yang menyesatkan.',
] },
{ title: 'Kekayaan intelektual', paragraphs: ['Layanan, perangkat lunak, desain, merek, dan konten berlisensi dimiliki oleh YMB atau pemberi lisensinya dan dilindungi oleh hukum kekayaan intelektual. Kecuali hak terbatas untuk menggunakan Layanan berdasarkan Ketentuan ini, tidak ada kepemilikan atau lisensi tambahan yang dialihkan kepada Anda.'] },
{ title: 'Penindakan konten dan penghentian', paragraphs: ['Kami dapat menghapus konten, membatasi fitur, menangguhkan, atau menghentikan akses jika secara wajar kami meyakini Anda melanggar Ketentuan, menimbulkan risiko hukum atau keamanan, atau merugikan pengguna lain maupun Layanan. Anda dapat berhenti menggunakan Layanan kapan saja. Ketentuan yang menurut sifatnya tetap berlaku setelah penghentian akan tetap berlaku.'] },
{ title: 'Penafian', paragraphs: ['Sejauh diizinkan oleh hukum, Layanan disediakan berdasarkan ketersediaan. Kami tidak menjamin ketersediaan tanpa gangguan, pengoperasian bebas kesalahan, atau bahwa setiap judul akan selalu tersedia. Tidak ada bagian dalam Ketentuan ini yang mengecualikan hak konsumen yang tidak dapat dikecualikan oleh hukum.'] },
{ title: 'Batasan tanggung jawab', paragraphs: ['Sejauh diizinkan oleh hukum, YMB tidak bertanggung jawab atas kerugian tidak langsung, insidental, khusus, atau konsekuensial yang timbul dari penggunaan Layanan. Hak atau tanggung jawab menurut hukum yang tidak dapat dibatasi tetap tidak terpengaruh.'] },
{ title: 'Hukum yang berlaku', paragraphs: ['Ketentuan ini diatur oleh hukum Republik Indonesia, tanpa memperhatikan prinsip pertentangan hukum. Pengadilan atau lembaga penyelesaian sengketa yang berwenang berdasarkan hukum Indonesia akan menangani sengketa, dengan tunduk pada perlindungan konsumen yang bersifat wajib.'] },
{ title: 'Perubahan dan kontak', paragraphs: ['Kami dapat merevisi Ketentuan ini dan menerbitkan versi terbaru dengan tanggal berlaku baru. Perubahan material akan diberitahukan lebih lanjut jika diwajibkan. Pertanyaan dapat dikirim menggunakan detail kontak di bawah.'] },
],
},
},
};
+32
View File
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
import { useLocale } from '../../app/providers/AppProviders';
import { SectionHeading } from '../../shared/components/SectionHeading';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { VideoGridSkeleton } from '../../shared/components/skeletons';
import { videos } from '../../shared/data/catalog';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { listingApi } from './api';
export function ListingPage({ type }) {
const { locale } = useLocale();
const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library;
const request = useRequest(
({ signal }) => listingApi.dramas({}, { signal }).then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))),
[locale.code],
);
const items = request.data?.length ? request.data : videos;
return (
<>
<SectionHeading title={title} />
{request.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{request.error && <div className="api-note warning">{locale.apiFallback}</div>}
<VideoGrid items={items} />
</>
)}
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
export const listingApi = {
dramas: (body = {}, options) => http.post('/app/dramas/page', {
pageIndex: 1,
pageSize: 20,
categoryIds: [],
classificationIds: [],
tagIds: [],
...body,
}, options),
};
+49
View File
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import { Search as SearchIcon } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { SectionHeading } from '../../shared/components/SectionHeading';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { VideoGridSkeleton } from '../../shared/components/skeletons';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { searchApi } from './api';
async function loadSearchResults(keyword, localeCode, signal) {
if (keyword) {
const payload = await searchApi.search(keyword, { signal });
return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode));
}
const payload = await dramaApi.homeFeed({ signal });
const items = payload?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
export function SearchPage() {
const { locale } = useLocale();
const [params] = useSearchParams();
const query = (params.get('q') || '').trim();
const request = useRequest(({ signal }) => loadSearchResults(query, locale.code, signal), [query, locale.code]);
const results = request.data || [];
return (
<>
<SectionHeading title={query ? `${locale.searchResults} “${query}”` : locale.picks} />
{request.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{request.error && <div className="api-note warning">{locale.apiFallback}</div>}
{!request.error && query && results.length === 0 ? (
<div className="search-empty">
<SearchIcon size={28} />
<b>{locale.noResults} “{query}”</b>
</div>
) : (
<VideoGrid items={results} />
)}
</>
)}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
import { toQuery } from '../../shared/api/query';
export const searchApi = {
search: (keyword, options) => http.get(`/app/search${toQuery({ keyword, pageIndex: 1, pageSize: 48 })}`, options),
};
+157
View File
@@ -0,0 +1,157 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocale, useUi } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { ShortsSkeleton } from '../../shared/components/skeletons';
import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useToast } from '../../shared/hooks/useToast';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { ShortSlide } from './components/ShortSlide';
const PAGE_SIZE = 6;
const PRELOAD_THRESHOLD = 2;
export function ShortsFeedPage() {
const { locale } = useLocale();
const { sidebarOpen } = useUi();
const [params] = useSearchParams();
const startId = params.get('start');
const [items, setItems] = useState([]);
const [activeId, setActiveId] = useState('');
const [status, setStatus] = useState({ loading: true, error: '', hasMore: true });
const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels();
const [commentsVideo, setCommentsVideo] = useState(null);
const toast = useToast();
const feedRef = useRef(null);
const cursorRef = useRef(null);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const loadPage = useCallback(async (reset) => {
if (loadingRef.current && !reset) return;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
loadingRef.current = true;
setStatus((prev) => ({ ...prev, loading: true, error: '' }));
try {
const payload = await dramaApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE });
if (requestId !== requestIdRef.current) return;
const list = uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)));
cursorRef.current = payload?.nextCursor ?? null;
setItems((prev) => (reset ? list : uniqueDramas([...prev, ...list])));
setStatus({ loading: false, error: '', hasMore: Boolean(payload?.hasMore) });
} catch (error) {
if (requestId !== requestIdRef.current) return;
setStatus((prev) => ({ ...prev, loading: false, error: error.message || 'Request failed' }));
} finally {
if (requestId === requestIdRef.current) loadingRef.current = false;
}
}, [locale.code]);
useEffect(() => {
cursorRef.current = null;
setItems([]);
setActiveId('');
setStatus({ loading: true, error: '', hasMore: true });
loadPage(true);
}, [loadPage]);
useEffect(() => {
if (!items.length) return;
if (items.some((item) => item.id === activeId)) return;
const startIndex = startId ? items.findIndex((item) => item.id === startId) : -1;
const nextIndex = startIndex >= 0 ? startIndex : 0;
const node = feedRef.current;
if (node && nextIndex > 0) node.scrollTop = nextIndex * node.clientHeight;
setActiveId(items[nextIndex].id);
}, [activeId, items, startId]);
useEffect(() => {
const node = feedRef.current;
if (!node) return undefined;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) setActiveId(entry.target.dataset.id);
});
}, { root: node, threshold: 0.7 });
node.querySelectorAll('[data-id]').forEach((slide) => observer.observe(slide));
return () => observer.disconnect();
}, [items.length]);
useEffect(() => {
if (!status.hasMore || status.loading || !activeId) return;
const index = items.findIndex((item) => item.id === activeId);
if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false);
}, [activeId, items, loadPage, status.hasMore, status.loading]);
const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId));
const move = (delta) => {
const node = feedRef.current;
if (!node) return;
const next = Math.min(items.length - 1, Math.max(0, activeIndex + delta));
if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' });
};
const shareVideo = async (video) => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast.show(locale.linkCopied);
} catch {
toast.show(url);
}
};
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); }
if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); }
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex, items.length]);
return (
<>
<section className={`shorts-stage ${sidebarOpen ? '' : 'shorts-stage-wide'}`}>
{status.loading && !items.length && <ShortsSkeleton label={locale.loading} />}
{status.error && <div className="shorts-state warning">{status.error}</div>}
{!status.loading && !status.error && !items.length && <div className="shorts-state">{locale.empty}</div>}
<div className="shorts-scroll" ref={feedRef} aria-label={locale.shortTitle}>
{items.map((video) => (
<ShortSlide
key={video.id}
video={video}
active={video.id === activeId}
followed={followedChannels.includes(video.channel)}
onToggleFollow={() => toggleFollow(video.channel)}
onOpenComments={() => setCommentsVideo(video)}
onShare={() => shareVideo(video)}
onEnded={() => move(1)}
/>
))}
</div>
{items.length > 1 && (
<div className="feed-arrows">
<button onClick={() => move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑</button>
<button onClick={() => move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓</button>
</div>
)}
</section>
{commentsVideo && <CommentsSheet video={commentsVideo} locale={locale} onClose={() => setCommentsVideo(null)} />}
{toast.message && <div className="shorts-toast">{toast.message}</div>}
</>
);
}
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../../app/providers/AppProviders';
import { VideoPlayer } from '../../../shared/components/player/VideoPlayer';
import { useEngagement } from '../../../shared/hooks/useEngagement';
import { useRequest } from '../../../shared/hooks/useRequest';
import { formatCount } from '../../../shared/lib/format';
import { selectPlaybackUrl } from '../../../shared/lib/media';
import { reportWatchProgress } from '../../../shared/lib/watchProgress';
import { dramaApi } from '../../../entities/drama/api';
export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare, onEnded }) {
const { locale } = useLocale();
const previewId = video.preview?.id ? String(video.preview.id) : '';
const fallbackSrc = video.preview?.playUrl || '';
const playback = useRequest(
({ signal }) => dramaApi.playback(previewId, { signal }),
[active, previewId],
{ enabled: active && Boolean(previewId) },
);
const src = active
? playback.data
? selectPlaybackUrl(playback.data, fallbackSrc)
: playback.error
? fallbackSrc
: ''
: '';
const likeTargetId = video.preview?.id || video.id;
const { liked, likeCount, saved, saveCount, toggleLike, toggleSave } = useEngagement({
likeTargetType: video.preview?.id ? 'EPISODE' : 'DRAMA',
likeTargetId,
dramaId: video.id,
initialLikeCount: video.likeCount,
initialSaveCount: video.favoriteCount,
});
return (
<article className="short-slide" data-id={video.id}>
<div className="short-slide-stage">
<div className="short-slide-info">
<div className="short-channel">
<span className="short-avatar">{video.channel?.[0] || 'Y'}</span>
<span className="short-channel-name">{video.channel}</span>
<button className={`short-follow ${followed ? 'followed' : ''}`} onClick={onToggleFollow} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<h1>{video.title}</h1>
</div>
<div className="short-column">
<div className="short-player-wrap">
<div className="short-slide-player">
{src
? <VideoPlayer src={src} poster={video.image} autoPlay={active} soundHint={locale.soundHint} onEnded={onEnded} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
: <div className="short-slide-placeholder"><img src={video.image} alt={video.title} /><span>{active && playback.loading ? locale.loading : active && playback.error ? locale.playbackUnavailable : video.title}</span></div>}
</div>
</div>
</div>
<div className="short-rail">
<button className={saved ? 'active' : ''} onClick={toggleSave} type="button" aria-label={locale.save}>
<span className="rail-icon"><Bookmark size={22} fill={saved ? 'currentColor' : 'none'} /></span>
<small>{formatCount(saveCount, locale.code)}</small>
</button>
<button className={liked ? 'active' : ''} onClick={toggleLike} type="button" aria-label={locale.like}>
<span className="rail-icon"><ThumbsUp size={22} fill={liked ? 'currentColor' : 'none'} /></span>
<small>{formatCount(likeCount, locale.code)}</small>
</button>
<button onClick={onOpenComments} type="button" aria-label={locale.comments}>
<span className="rail-icon"><MessageCircle size={22} /></span>
<small>{formatCount(video.commentCount, locale.code)}</small>
</button>
<button onClick={onShare} type="button" aria-label={locale.share}>
<span className="rail-icon"><Share2 size={22} /></span>
<small>{locale.share}</small>
</button>
</div>
</div>
</article>
);
}
+234
View File
@@ -0,0 +1,234 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { HeroTransition } from '../../shared/components/HeroTransition';
import { VideoPlayer } from '../../shared/components/player/VideoPlayer';
import { WatchPageSkeleton } from '../../shared/components/skeletons';
import { useEngagement } from '../../shared/hooks/useEngagement';
import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useRequest } from '../../shared/hooks/useRequest';
import { useToast } from '../../shared/hooks/useToast';
import { formatCount, formatViews } from '../../shared/lib/format';
import { getHeroTransition } from '../../shared/lib/heroTransition';
import { selectPlaybackUrl } from '../../shared/lib/media';
import { reportWatchProgress } from '../../shared/lib/watchProgress';
import { normalizeDrama } from '../../entities/drama/model';
import { dramaApi } from '../../entities/drama/api';
import { EpisodeRail, episodeLabel } from './components/EpisodeRail';
function collectEpisodes(video) {
if (!video.episodes?.length) return video.preview ? [video.preview] : [];
const hasPreview = video.preview && !video.episodes.some((item) => item.id === video.preview.id);
return hasPreview ? [video.preview, ...video.episodes] : video.episodes;
}
function WatchContent({ video }) {
const { locale } = useLocale();
const episodes = collectEpisodes(video);
const [episodeId, setEpisodeId] = useState(video.preview?.id || episodes[0]?.id);
const episodeIndex = Math.max(0, episodes.findIndex((item) => item.id === episodeId));
const currentEpisode = episodes[episodeIndex] || episodes[0] || null;
const nextEpisode = episodes[episodeIndex + 1];
const previousEpisode = episodes[episodeIndex - 1];
const toast = useToast();
const playback = useRequest(
({ signal }) => dramaApi.playback(currentEpisode.id, { signal }),
[currentEpisode?.id],
{ enabled: Boolean(currentEpisode?.id) && !currentEpisode?.locked },
);
const stats = useRequest(
({ signal }) => dramaApi.stats(currentEpisode.id, { signal }),
[currentEpisode?.id],
{ enabled: Boolean(currentEpisode?.id) },
);
const engagement = useEngagement({
likeTargetType: 'EPISODE',
likeTargetId: currentEpisode?.id,
dramaId: video.id,
initialLikeCount: currentEpisode?.likeCount,
initialSaveCount: video.favoriteCount,
});
const { channels, toggle } = useFollowedChannels();
const followed = channels.includes(video.channel);
const [commentsOpen, setCommentsOpen] = useState(false);
const swipeStart = useRef(null);
const playerRef = useRef(null);
const wheelRef = useRef({ accum: 0, locked: false, lastAt: 0, lastAbs: 0 });
const endedTimerRef = useRef(null);
useEffect(() => () => {
if (endedTimerRef.current) window.clearTimeout(endedTimerRef.current);
}, []);
useEffect(() => {
engagement.syncStats(stats.data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stats.data]);
const changeEpisode = (episode, scroll = false) => {
if (!episode || episode.id === currentEpisode?.id) return;
if (endedTimerRef.current) window.clearTimeout(endedTimerRef.current);
setEpisodeId(episode.id);
if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const goNext = () => changeEpisode(nextEpisode);
const goPrevious = () => changeEpisode(previousEpisode);
const handleEnded = () => {
if (!nextEpisode || nextEpisode.locked) return;
endedTimerRef.current = window.setTimeout(() => changeEpisode(nextEpisode), 320);
};
useEffect(() => {
// 捕获阶段接管画面内的滚轮,任何幅度都不交给页面滚动
const onWheel = (event) => {
if (!event.deltaY) return;
// 用坐标判断是否在播放区内:切集后 video 节点会被替换,事件 target 可能失效
const rect = playerRef.current?.getBoundingClientRect();
const inside = rect
? event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom
: Boolean(event.target?.closest?.('.watch-stage'));
if (!inside) return;
event.preventDefault();
const state = wheelRef.current;
const now = performance.now();
// 兼容按行滚动的设备(Firefox 等),统一成像素
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const magnitude = Math.abs(delta);
// 新手势:停顿超过 60ms,或惯性衰减中突然重新加速(新的一次滑动)
if (now - state.lastAt > 60 || magnitude > state.lastAbs * 2.2 + 8) state.locked = false;
state.lastAt = now;
state.lastAbs = magnitude;
if (state.accum && Math.sign(delta) !== Math.sign(state.accum)) state.accum = delta;
else state.accum += delta;
if (state.locked || Math.abs(state.accum) < 24) return;
const forward = state.accum > 0;
if (forward ? !nextEpisode : !previousEpisode) return;
state.locked = true;
state.accum = 0;
if (forward) goNext(); else goPrevious();
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => window.removeEventListener('wheel', onWheel, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextEpisode, previousEpisode]);
if (!currentEpisode) return <div className="api-note warning">{locale.playbackUnavailable}</div>;
const src = currentEpisode.locked ? '' : selectPlaybackUrl(playback.data, currentEpisode.playUrl);
const views = formatViews(video.views, locale.code);
const tag = video.genre ? `#${video.genre.replace(/\s+/g, '')}` : '';
const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0;
const label = episodeLabel(currentEpisode, episodeIndex, locale.code);
const commentVideo = { ...video, preview: currentEpisode };
const onTouchStart = (event) => {
const touch = event.touches[0];
swipeStart.current = { x: touch.clientX, y: touch.clientY };
};
const onTouchEnd = (event) => {
const start = swipeStart.current;
swipeStart.current = null;
if (!start) return;
const touch = event.changedTouches[0];
const dx = start.x - touch.clientX;
const dy = start.y - touch.clientY;
if (Math.abs(dy) < 50 || Math.abs(dx) > Math.abs(dy)) return;
if (dy > 0) goNext(); else goPrevious();
};
const share = async () => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast.show(locale.linkCopied);
} catch {
toast.show(url);
}
};
return (
<div className="watch-page">
<div className="watch-primary">
<div ref={playerRef} className="watch-stage" style={{ '--watch-poster': `url("${video.image}")` }}>
<div className="watch-player" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{src
? <VideoPlayer key={currentEpisode.id} src={src} poster={video.image} autoPlay soundHint={locale.soundHint} onEnded={handleEnded} onProgress={(position, duration, flush) => reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
: <div className="watch-player-placeholder"><img src={video.image} alt={video.title} /><span>{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}</span></div>}
<span className="watch-swipe-tip">{locale.episodeSwipeHint}</span>
<div className="watch-arrows">
<button onClick={goPrevious} disabled={!previousEpisode} aria-label="Previous episode" type="button">↑</button>
<button onClick={goNext} disabled={!nextEpisode} aria-label="Next episode" type="button">↓</button>
</div>
</div>
</div>
<div className="watch-meta">
<span className="watch-badge">{label}</span>
<span>{views}{tag ? ` · ${tag}` : ''}</span>
</div>
<h1 className="watch-title">{video.title}</h1>
<div className="watch-toolbar">
<div className="watch-channel">
<span className="watch-avatar">{video.channel?.[0] || 'Y'}</span>
<div className="watch-channel-copy"><b>{video.channel}</b></div>
<button className={`watch-subscribe ${followed ? 'followed' : ''}`} onClick={() => toggle(video.channel)} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<div className="watch-actions">
<button className={engagement.liked ? 'active' : ''} onClick={engagement.toggleLike} type="button"><ThumbsUp size={18} fill={engagement.liked ? 'currentColor' : 'none'} /><span>{formatCount(engagement.likeCount, locale.code)}</span></button>
<button className={engagement.saved ? 'active' : ''} onClick={engagement.toggleSave} type="button"><Bookmark size={18} fill={engagement.saved ? 'currentColor' : 'none'} /><span>{formatCount(engagement.saveCount, locale.code)}</span></button>
<button onClick={() => setCommentsOpen(true)} type="button"><MessageCircle size={18} /><span>{formatCount(commentCount, locale.code)}</span></button>
<button onClick={share} type="button"><Share2 size={18} /><span>{locale.share}</span></button>
</div>
</div>
{video.description && (
<div className="watch-description">
<p>{video.description}</p>
</div>
)}
</div>
<EpisodeRail episodes={episodes} currentId={currentEpisode.id} image={video.image} onSelect={(episode) => changeEpisode(episode, true)} />
{commentsOpen && <CommentsSheet video={commentVideo} locale={locale} onClose={() => setCommentsOpen(false)} />}
{toast.message && <div className="shorts-toast">{toast.message}</div>}
</div>
);
}
export function WatchPage() {
const { id } = useParams();
const { locale } = useLocale();
const [hero, setHero] = useState(() => getHeroTransition());
const clearHero = useCallback(() => setHero(null), []);
const drama = useRequest(
({ signal }) => dramaApi.get(id, { signal }).then((data) => normalizeDrama(data, locale.code)),
[id, locale.code],
);
useEffect(() => {
window.scrollTo({ top: 0 });
const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0);
return () => window.clearTimeout(timer);
}, [id]);
return (
<>
<HeroTransition data={hero} onDone={clearHero} />
{!drama.data && drama.loading && <WatchPageSkeleton label={locale.loading} />}
{!drama.data && !drama.loading && <div className="api-note warning">{drama.error?.message || locale.empty}</div>}
{drama.data && <WatchContent key={drama.data.id} video={drama.data} />}
</>
);
}
@@ -0,0 +1,43 @@
// Author: fruit.com.cn
import { Lock } from 'lucide-react';
import { useLocale } from '../../../app/providers/AppProviders';
export function episodeLabel(episode, index, code) {
const number = episode?.number ?? index + 1;
return code === 'zh' ? `第${number}集` : `Episode ${number}`;
}
export function EpisodeRail({ episodes, currentId, image, onSelect }) {
const { locale } = useLocale();
if (!episodes.length) return null;
const currentIndex = Math.max(0, episodes.findIndex((episode) => episode.id === currentId));
return (
<aside className="watch-side">
<h2>
{locale.episodes}
<span className="watch-episode-count">{currentIndex + 1} / {episodes.length}</span>
</h2>
<div className="watch-episode-list">
{episodes.map((episode, index) => (
<button
className={`watch-episode-item ${episode.id === currentId ? 'active' : ''}`}
key={episode.id}
onClick={() => onSelect(episode)}
type="button"
>
<span className="watch-episode-thumb">
<img src={image} alt="" loading="lazy" />
<b>{episode.number ?? index + 1}</b>
{episode.locked && <span className="watch-episode-lock"><Lock size={16} /></span>}
</span>
<span className="watch-episode-copy">
<b>{episodeLabel(episode, index, locale.code)}</b>
<small>{episode.duration || ''}</small>
</span>
</button>
))}
</div>
</aside>
);
}
-13
View File
@@ -1,13 +0,0 @@
// 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
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn
export const locales = {
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', voiceSearch: 'Cari dengan suara', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], homeCategories: ['Semua', 'Podcast', 'Musik', 'Game', 'Live', 'Travel', 'Baru diupload', 'Temukan video baru'], 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', voiceSearch: 'Search with your voice', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], homeCategories: ['All', 'Podcasts', 'Music', 'Gaming', 'Live', 'Travel', 'Recently uploaded', 'New to you'], 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: '搜索短剧、演员或题材', voiceSearch: '通过语音搜索内容', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], homeCategories: ['全部', '播客', '音乐', '游戏', '直播', '旅游', '最近上传', '发现新视频'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', searchResults: 'Hasil pencarian untuk', noResults: 'Tidak ada hasil untuk', clearSearch: 'Hapus pencarian', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', clearHistory: 'Hapus semua', historyEmpty: 'Belum ada riwayat tontonan.', comments: 'Komentar', noComments: 'Belum ada komentar.', commentPlaceholder: 'Tambah komentar…', send: 'Kirim', share: 'Bagikan', save: 'Simpan', like: 'Suka', linkCopied: 'Tautan disalin', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Unggah video', uploadHint: 'Seret file video ke sini, atau klik untuk memilih', uploadHintSub: 'MP4, MOV atau WebM', uploadTitleLabel: 'Judul', uploadTitlePlaceholder: 'Beri judul untuk videomu', uploadCancel: 'Batal', uploadSubmit: 'Unggah', uploadSuccessTitle: 'Video dikirim untuk ditinjau', uploadSuccessText: 'Setelah disetujui, video akan tampil di channel-mu.', uploadDone: 'Selesai', close: 'Tutup', episodes: 'Episode', episodeSwipeHint: 'Geser atas/bawah untuk ganti episode', lockedEpisode: 'Masuk untuk membuka episode ini.', soundHint: 'Ketuk untuk suara', about: 'Tentang YMB', copyright: 'YMB · Your Mini Business. Hak cipta dilindungi.', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', searchResults: 'Search results for', noResults: 'No results for', clearSearch: 'Clear search', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', clearHistory: 'Clear all', historyEmpty: 'No watch history yet.', comments: 'Comments', noComments: 'No comments yet.', commentPlaceholder: 'Add a comment…', send: 'Send', share: 'Share', save: 'Save', like: 'Like', linkCopied: 'Link copied', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Upload video', uploadHint: 'Drag and drop a video file here, or click to browse', uploadHintSub: 'MP4, MOV or WebM', uploadTitleLabel: 'Title', uploadTitlePlaceholder: 'Give your video a title', uploadCancel: 'Cancel', uploadSubmit: 'Upload', uploadSuccessTitle: 'Video submitted for review', uploadSuccessText: 'Once approved, it will appear on your channel.', uploadDone: 'Done', close: 'Close', episodes: 'Episodes', episodeSwipeHint: 'Swipe up/down to change episodes', lockedEpisode: 'Login to unlock this episode.', soundHint: 'Tap for sound', about: 'About YMB', copyright: 'YMB · Your Mini Business. All rights reserved.', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', searchResults: '搜索结果', noResults: '没有找到相关结果', clearSearch: '清空搜索', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', clearHistory: '清空记录', historyEmpty: '还没有观看记录。', comments: '评论', noComments: '还没有评论。', commentPlaceholder: '添加评论…', send: '发送', share: '分享', save: '收藏', like: '点赞', linkCopied: '链接已复制', subscribe: '订阅', subscribed: '已订阅', uploadVideo: '上传视频', uploadHint: '拖拽视频文件到此处,或点击选择', uploadHintSub: '支持 MP4 / MOV / WebM', uploadTitleLabel: '标题', uploadTitlePlaceholder: '为视频添加标题', uploadCancel: '取消', uploadSubmit: '上传', uploadSuccessTitle: '视频已提交审核', uploadSuccessText: '审核通过后会在你的频道中展示。', uploadDone: '完成', close: '关闭', episodes: '剧集', episodeSwipeHint: '上下滑动切换剧集', lockedEpisode: '登录后解锁本集。', soundHint: '点击开启声音', about: '关于 YMB', copyright: 'YMB · Your Mini Business. 版权所有。', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
};
export function getLocale(code) { return locales[code] || locales.id; }
+41 -73
View File
@@ -1,82 +1,50 @@
// Author: fruit.com.cn
import { Bell, Bookmark, ChevronDown, ChevronRight, Compass, Film, History, Home, ListVideo, Menu, Mic, Music2, Plus, Radio, Search, ThumbsUp, UserRound, Video, X } from 'lucide-react';
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { Suspense, useState } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useLocale, useUi } from '../app/providers/AppProviders';
import { Sidebar } from './components/Sidebar';
import { TopBar } from './components/TopBar';
import { UploadVideoDialog } from './components/UploadVideoDialog';
const FOOTER_LINKS = {
id: { privacy: 'Privasi', terms: 'Ketentuan', about: 'Tentang YMB' },
en: { privacy: 'Privacy', terms: 'Terms', about: 'About YMB' },
zh: { privacy: '隐私政策', terms: '服务条款', about: '关于 YMB' },
};
export function AppShell() {
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
const navigate = useNavigate();
const [searchOpen, setSearchOpen] = useState(false);
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions']];
const libraryItems = [[History, locale.history, '/history'], [ListVideo, locale.playlists, '/library'], [Bookmark, locale.watchLater, '/library'], [ThumbsUp, locale.likedVideos, '/library'], [Video, locale.myVideos, '/library']];
const exploreItems = [[Music2, locale.music, '/search?genre=music'], [Film, locale.movies, '/search?genre=movies'], [Radio, locale.live, '/shorts']];
const submitSearch = (event) => {
event.preventDefault();
navigate(`/search?q=${encodeURIComponent(query)}`);
};
const { locale } = useLocale();
const { sidebarOpen, setSidebarOpen } = useUi();
const location = useLocation();
const [uploadOpen, setUploadOpen] = useState(false);
const immersive = location.pathname === '/shorts';
const links = FOOTER_LINKS[locale.code] || FOOTER_LINKS.en;
return (
<>
<header className="topbar">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={22} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
<button aria-label="Search" type="submit"><Search size={19} /></button>
</form>
<button className="voice-search icon-button" aria-label={locale.voiceSearch} type="button"><Mic size={20} /></button>
<div className="top-actions">
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"><Search size={20} /></button>
<LanguageSwitcher />
<button className="create-button" type="button"><Plus size={19} /><span>{locale.create}</span></button>
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={19} /></button>
<span className="profile-chip" aria-label={locale.account}>N</span>
</div>
</header>
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<nav className="sidebar-group">
{primaryItems.map(([Icon, label, to]) => (
<NavLink to={to} end={to === '/'} key={to}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
</nav>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title"><span>{locale.you}</span><ChevronRight size={16} /></div>
{libraryItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
</div>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title">{locale.explore}</div>
{exploreItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
<button className="genre-link" type="button"><ChevronDown size={19} /><span className="sidebar-label">{locale.more}</span></button>
</div>
</aside>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet /></main>
<nav className="bottom-nav">
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}
</nav>
{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button"><X /></button>}
<TopBar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onUpload={() => setUploadOpen(true)} />
<Sidebar />
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}>
<Suspense fallback={<div className="route-loading" aria-busy="true" />}>
<Outlet />
</Suspense>
{!immersive && (
<footer className="site-footer">
<div>© {new Date().getFullYear()} {locale.copyright}</div>
<nav className="site-footer-links" aria-label="Legal and company information">
<Link to="/privacy">{links.privacy}</Link>
<span aria-hidden="true">·</span>
<Link to="/terms">{links.terms}</Link>
<span aria-hidden="true">·</span>
<Link to="/about">{links.about}</Link>
</nav>
</footer>
)}
</main>
{sidebarOpen && (
<button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button" />
)}
<UploadVideoDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
import { Plus, Upload } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
export function CreateMenu({ onUpload }) {
const { locale } = useLocale();
const [open, setOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
if (!open) return undefined;
const closeMenu = (event) => {
if (!menuRef.current?.contains(event.target)) setOpen(false);
};
document.addEventListener('mousedown', closeMenu);
return () => document.removeEventListener('mousedown', closeMenu);
}, [open]);
const selectUpload = () => {
setOpen(false);
onUpload?.();
};
return (
<div className="create-wrap" ref={menuRef}>
<button className="create-button" type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
<Plus size={20} /><span>{locale.create}</span>
</button>
{open && (
<div className="create-menu" role="menu">
<button type="button" role="menuitem" onClick={selectUpload}>
<Upload size={18} /><span>{locale.uploadVideo}</span>
</button>
</div>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Author: fruit.com.cn
// 用内联 SVG 绘制国旗,避免 Windows 等系统不渲染 emoji 国旗
const STAR = 'M0,-1 L0.2245,-0.309 L0.951,-0.309 L0.363,0.118 L0.588,0.809 L0,0.382 L-0.588,0.809 L-0.363,0.118 L-0.951,-0.309 L-0.2245,-0.309 Z';
const FLAGS = {
id: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#fff" />
<rect width="3" height="1" fill="#ce1126" />
</svg>
),
en: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#fff" />
{[0, 2, 4, 6, 8, 10, 12].map((index) => (
<rect key={index} y={(index * 2) / 13} width="3" height={2 / 13} fill="#b22234" />
))}
<rect width="1.2" height={14 / 13} fill="#3c3b6e" />
{[0, 1, 2, 3, 4].map((row) => (
[0, 1, 2, 3, 4, 5].map((col) => (
<circle key={`${row}-${col}`} cx={0.1 + col * 0.2} cy={0.11 + row * 0.22} r={0.05} fill="#fff" />
))
))}
</svg>
),
zh: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#de2910" />
<path d={STAR} transform="translate(0.5 0.5) scale(0.28)" fill="#ffde00" />
<path d={STAR} transform="translate(1 0.28) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1.18 0.5) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1.18 0.74) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1 0.96) scale(0.1)" fill="#ffde00" />
</svg>
),
};
export function LanguageFlag({ code }) {
return <span className="language-flag">{FLAGS[code] || FLAGS.id}</span>;
}
@@ -0,0 +1,43 @@
// Author: fruit.com.cn
import { Check, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
import { LanguageFlag } from './LanguageFlag';
const languages = [
{ code: 'id', label: 'Bahasa Indonesia', short: 'ID' },
{ code: 'en', label: 'English', short: 'EN' },
{ code: 'zh', label: '简体中文', short: '中文' },
];
const titles = { id: 'Bahasa', en: 'Language', zh: '语言' };
export function LanguageSwitcher() {
const { lang, setLang } = useLocale();
const [open, setOpen] = useState(false);
const current = languages.find((item) => item.code === lang) || languages[0];
const title = titles[lang] || 'Language';
return (
<div className="language-switcher">
<button className="language-trigger" onClick={() => setOpen(!open)} aria-expanded={open} aria-label={title} type="button">
<LanguageFlag code={current.code} />
<span className="language-code">{current.short}</span>
<ChevronDown size={14} className="language-chevron" />
</button>
{open && (
<>
<button className="language-backdrop" onClick={() => setOpen(false)} aria-label="Close language menu" type="button" />
<div className="language-menu">
<div className="language-menu-header">{title}</div>
{languages.map((item) => (
<button key={item.code} className={item.code === lang ? 'selected' : ''} onClick={() => { setLang(item.code); setOpen(false); }} type="button">
<LanguageFlag code={item.code} />
<span className="language-option-label">{item.label}</span>
{item.code === lang && <Check size={16} className="language-option-check" />}
</button>
))}
</div>
</>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
import { Search, X } from 'lucide-react';
import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocale, useUi } from '../../app/providers/AppProviders';
export function SearchBox({ open = false, onSubmitted }) {
const { locale } = useLocale();
const { query, setQuery } = useUi();
const navigate = useNavigate();
const inputRef = useRef(null);
const submit = (event) => {
event.preventDefault();
const keyword = query.trim();
onSubmitted?.();
navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search');
};
return (
<form className={`search-form ${open ? 'open' : ''}`} onSubmit={submit}>
<span className="search-field">
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={locale.search}
aria-label={locale.search}
/>
{query && (
<button
className="search-clear"
type="button"
aria-label={locale.clearSearch}
onClick={() => {
setQuery('');
inputRef.current?.focus();
}}
>
<X size={18} />
</button>
)}
</span>
<button aria-label="Search" type="submit"><Search size={20} /></button>
</form>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Author: fruit.com.cn
import { Compass, History, Home, Info, ListVideo, UserRound } from 'lucide-react';
import { NavLink, useLocation } from 'react-router-dom';
import { useLocale, useUi } from '../../app/providers/AppProviders';
export function Sidebar() {
const { locale } = useLocale();
const { sidebarOpen, setSidebarOpen } = useUi();
const location = useLocation();
const primaryItems = [
[Home, locale.home, '/'],
[Compass, locale.shorts, '/shorts'],
[ListVideo, locale.subscriptions, '/subscriptions'],
];
const navClass = (to) => {
if (location.pathname !== to) return '';
return 'active';
};
// 移动端为抽屉式侧边栏,点击任一菜单项后必须收起
const closeOnNavigate = (event) => {
if (!event.target.closest?.('a')) return;
if (window.matchMedia('(max-width: 760px)').matches) setSidebarOpen(false);
};
return (
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`} onClick={closeOnNavigate}>
<nav className="sidebar-group">
{primaryItems.map(([Icon, label, to]) => (
<NavLink to={to} className={() => navClass(to)} key={to}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
</nav>
<div className="nav-rule" />
<div className="sidebar-group">
<NavLink to="/library" className={() => navClass('/library')}>
<UserRound size={19} />
<span className="sidebar-label">{locale.you}</span>
</NavLink>
<NavLink to="/history" className={() => navClass('/history')}>
<History size={19} />
<span className="sidebar-label">{locale.history}</span>
</NavLink>
</div>
<div className="nav-rule" />
<div className="sidebar-group">
<NavLink to="/about" className={() => navClass('/about')}>
<Info size={19} />
<span className="sidebar-label">{locale.about}</span>
</NavLink>
</div>
</aside>
);
}
+38
View File
@@ -0,0 +1,38 @@
// Author: fruit.com.cn
import { Bell, Menu, Search } from 'lucide-react';
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { CreateMenu } from './CreateMenu';
import { LanguageSwitcher } from './LanguageSwitcher';
import { SearchBox } from './SearchBox';
export function TopBar({ onToggleSidebar, onUpload }) {
const { locale } = useLocale();
const [searchOpen, setSearchOpen] = useState(false);
return (
<header className="topbar">
<div className="topbar-left">
<button className="icon-button topbar-menu" onClick={onToggleSidebar} aria-label="Menu" type="button">
<Menu size={24} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
</div>
<div className="topbar-center">
<SearchBox open={searchOpen} onSubmitted={() => setSearchOpen(false)} />
</div>
<div className="top-actions">
<button className="icon-button mobile-search" onClick={() => setSearchOpen((open) => !open)} aria-expanded={searchOpen} aria-label={locale.search} type="button">
<Search size={22} />
</button>
<LanguageSwitcher />
<CreateMenu onUpload={onUpload} />
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={20} /></button>
<span className="profile-chip" aria-label={locale.account}>N</span>
</div>
</header>
);
}
@@ -0,0 +1,100 @@
// Author: fruit.com.cn
import { CheckCircle2, UploadCloud, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
function formatSize(bytes) {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`;
}
export function UploadVideoDialog({ open, onClose }) {
const { locale } = useLocale();
const [file, setFile] = useState(null);
const [title, setTitle] = useState('');
const [dragging, setDragging] = useState(false);
const [submitted, setSubmitted] = useState(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return undefined;
setFile(null);
setTitle('');
setDragging(false);
setSubmitted(false);
const onKeyDown = (event) => {
if (event.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open) return null;
const pickFile = (files) => {
const next = files?.[0];
if (next?.type?.startsWith('video/')) setFile(next);
};
return (
<div
className="upload-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="upload-dialog" role="dialog" aria-modal="true" aria-labelledby="upload-dialog-title">
<header>
<h2 id="upload-dialog-title">{locale.uploadVideo}</h2>
<button className="icon-button" type="button" aria-label={locale.close} onClick={onClose}><X size={20} /></button>
</header>
{submitted ? (
<div className="upload-done">
<CheckCircle2 size={44} />
<h3>{locale.uploadSuccessTitle}</h3>
<p>{locale.uploadSuccessText}</p>
<button className="upload-submit" type="button" onClick={onClose}>{locale.uploadDone}</button>
</div>
) : (
<>
<label
className={`upload-drop ${dragging ? 'dragging' : ''}`}
onDragOver={(event) => { event.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(event) => { event.preventDefault(); setDragging(false); pickFile(event.dataTransfer.files); }}
>
<input type="file" accept="video/*" hidden onChange={(event) => pickFile(event.target.files)} />
<UploadCloud size={34} />
<b>{file ? file.name : locale.uploadHint}</b>
<small>{file ? formatSize(file.size) : locale.uploadHintSub}</small>
</label>
<label className="upload-field">
<span>{locale.uploadTitleLabel}</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={locale.uploadTitlePlaceholder}
maxLength={100}
/>
</label>
<footer className="upload-actions">
<button className="upload-cancel" type="button" onClick={onClose}>{locale.uploadCancel}</button>
<button
className="upload-submit"
type="button"
disabled={!file || !title.trim()}
onClick={() => setSubmitted(true)}
>
{locale.uploadSubmit}
</button>
</footer>
</>
)}
</div>
</div>
);
}
+4 -3
View File
@@ -1,8 +1,9 @@
// Author: fruit.com.cn
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles.css';
import './enterprise.css';
import './youtube-overrides.css';
import './shared/styles/base.css';
import './shared/styles/enterprise.css';
import './shared/styles/youtube-overrides.css';
import './features/legal/legal.css';
createRoot(document.getElementById('root')).render(<App />);
-91
View File
@@ -1,91 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useMemo, useState } from 'react';
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { videos } from '../data/catalog';
import { useAppStore } from '../app/store';
import { searchCatalog } from '../services/catalogService';
import { appApi } from '../services/appApi';
import { normalizeDrama, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
const SHORTS_LIMIT = 5;
const FIRST_SHELF_LIMIT = 5;
const ALL_GENRES_INDEX = 0;
function normalizeHomeFeed(payload, localeCode) {
const sections = payload?.sections || [];
const dramas = sections.flatMap((section) => section.dramas || []);
return uniqueDramas(dramas.map((item) => normalizeDrama(item, localeCode)));
}
function filterByGenre(items, genre, localeGenres) {
if (!genre || genre === localeGenres[ALL_GENRES_INDEX]) return items;
const filtered = items.filter((item) => item.genre === genre);
return filtered.length ? filtered : items;
}
export function HomePage() {
const { locale } = useAppStore();
const categoryItems = locale.homeCategories || locale.genres;
const [genre, setGenre] = useState(categoryItems[ALL_GENRES_INDEX]);
const api = useApi(
() => appApi.homeFeed().then((payload) => normalizeHomeFeed(payload, locale.code)),
[locale.code],
);
const remoteItems = api.data || [];
const fallbackItems = searchCatalog('', genre, locale.genres.indexOf(genre));
const items = useMemo(
() => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, categoryItems),
[categoryItems, fallbackItems, genre, remoteItems],
);
const shortsItems = (remoteItems.length ? remoteItems : videos).slice(0, SHORTS_LIMIT);
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
useEffect(() => {
setGenre(categoryItems[ALL_GENRES_INDEX]);
}, [categoryItems, locale.code]);
return (
<>
<section className="youtube-home-feed" aria-label={locale.home}>
<div className="youtube-category-bar" role="tablist" aria-label="Genre filters">
{categoryItems.map((item) => (
<button
className={`youtube-category-chip ${genre === item ? 'active' : ''}`}
key={item}
type="button"
role="tab"
aria-selected={genre === 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" id="recommended-title">
{firstShelf.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
<div className="youtube-shelf-heading">
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
<button type="button">{locale.explore}</button>
</div>
<div className="shorts-row" id="shorts-title">
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact />)}
</div>
</section>
{secondShelf.length > 0 && <section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
<div className="video-grid">
{secondShelf.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
</section>}
</>
);
}
-10
View File
@@ -1,10 +0,0 @@
// Author: fruit.com.cn
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { videos } from '../data/catalog';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
export function ListingPage({ type }) { const { locale } = useAppStore(); const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data?.length ? api.data : videos; return <><SectionHeading title={title} /><div className="api-note">{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}</div><div className="video-grid">{items.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
-11
View File
@@ -1,11 +0,0 @@
// 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';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
export function SearchPage() { const { locale } = useAppStore(); const [params] = useSearchParams(); const query = params.get('q') || ''; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const source = api.data?.length ? api.data : searchCatalog('', '', 0); const normalizedQuery = query.toLowerCase(); const results = source.filter((video) => `${video.title} ${video.channel} ${video.genre}`.toLowerCase().includes(normalizedQuery)); return <><SectionHeading title={query ? `Search results for “${query}”` : locale.picks} /><div className="api-note">{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}</div><div className="video-grid">{results.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
-13
View File
@@ -1,13 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } 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, selectPlaybackUrl, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
import { useAppStore } from '../app/store';
import { VideoPlayer } from '../components/player/VideoPlayer';
function FeedItem({ video }) { const ref = useRef(null); const [active, setActive] = useState(false); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); useEffect(() => { const element = ref.current; const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), { threshold: 0.7 }); observer.observe(element); return () => observer.disconnect(); }, []); return <article ref={ref} className="feed-item"><div className="feed-video-shell">{src ? <VideoPlayer src={src} autoPlay={active} /> : <img src={video.image} alt={video.title} />}</div><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) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data || []; 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>}{api.error && <div className="api-note warning">{api.error}</div>}{!api.loading && !items.length && <div className="api-note">{locale.empty}</div>}{items.map((video) => <FeedItem key={video.id} video={video} />)}</section>; }
-40
View File
@@ -1,40 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, selectPlaybackUrl, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
import { VideoPlayer } from '../components/player/VideoPlayer';
function SwipeVideo({ video, active }) {
const ref = useRef(null); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); const image = video.image;
useEffect(() => { const node = ref.current; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) node.classList.add('visible'); else node.classList.remove('visible'); }, { threshold: 0.7 }); observer.observe(node); return () => observer.disconnect(); }, []);
return <article ref={ref} className={`single-video-slide ${active ? 'active' : ''}`}><div className="single-video-stage">{src ? <VideoPlayer src={src} poster={image} autoPlay={active} /> : <div className="single-video-placeholder"><img src={image} alt={video.title} /><span>{playback.loading ? 'Loading video…' : playback.error || 'Preview unavailable'}</span></div>}</div><div className="single-video-info"><span>YMB SHORTS</span><h1>{video.title}</h1><p>{video.channel} · {video.views}</p></div></article>;
}
function normalizeFeedItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
async function loadWatchFeed(localeCode) {
try {
const page = normalizeFeedItems(await appApi.dramas(), localeCode);
if (page.length) return page;
} catch (error) {
const fallback = await appApi.homeFeed();
const items = fallback?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
const fallback = await appApi.homeFeed();
const items = fallback?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
export function WatchPage() {
const { id } = useParams(); const navigate = useNavigate(); const { locale } = useAppStore(); const [activeId, setActiveId] = useState(id); const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); const feed = useApi(() => loadWatchFeed(locale.code), [locale.code]); const items = feed.data || []; const ordered = uniqueDramas([current.data, ...items.filter((item) => item.id !== String(id))].filter(Boolean));
const activeIndex = Math.max(0, ordered.findIndex((item) => String(item.id) === String(activeId))); const feedRef = useRef(null); const touchStart = useRef(0); const scrollToIndex = (index) => { const node = feedRef.current; if (!node) return; node.scrollTo({ top: index * node.clientHeight, behavior: 'smooth' }); }; const move = (delta) => { if (ordered.length < 2) return; scrollToIndex(Math.min(ordered.length - 1, Math.max(0, activeIndex + delta))); };
useEffect(() => { const node = feedRef.current; if (!node) return undefined; const observer = new IntersectionObserver((entries) => entries.forEach((entry) => { if (entry.isIntersecting) { const nextId = entry.target.dataset.id; setActiveId(nextId); if (nextId !== id) window.history.replaceState(null, '', `/watch/${nextId}`); } }), { root: node, threshold: 0.75 }); node.querySelectorAll('[data-id]').forEach((item) => observer.observe(item)); const keydown = (event) => { if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); } if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); } }; window.addEventListener('keydown', keydown); return () => { observer.disconnect(); window.removeEventListener('keydown', keydown); }; }, [ordered.length, id, activeIndex]);
const hasNext = feed.loading || activeIndex < ordered.length - 1; const hasPrevious = activeIndex > 0;
return <div className="single-video-page"><header className="single-video-toolbar"><button onClick={() => navigate(-1)}>←</button><img src="/brand/ymb-logo-blue.png" alt="YMB" /><span>{locale.swipeHint || 'Swipe up to discover the next story'}</span></header>{(current.loading || feed.loading) && <div className="single-video-loading api-note">{locale.loading}</div>}{feed.error && <div className="single-video-loading api-note warning">{feed.error}</div>}<div ref={feedRef} className="single-video-feed" onTouchStart={(event) => { touchStart.current = event.touches[0].clientY; }} onTouchEnd={(event) => { const distance = touchStart.current - event.changedTouches[0].clientY; if (Math.abs(distance) > 45) move(distance > 0 ? 1 : -1); }} onWheel={(event) => { if (Math.abs(event.deltaY) > 20) move(event.deltaY > 0 ? 1 : -1); }}>{ordered.map((video) => <div key={video.id} data-id={video.id}><SwipeVideo video={video} active={String(activeId) === String(video.id)} /></div>)}</div><div className="feed-arrows"><button onClick={() => move(-1)} disabled={!hasPrevious} aria-label="Previous video">↑</button><button onClick={() => move(1)} disabled={!hasNext} aria-label="Next video">↓</button></div></div>;
}
-11
View File
@@ -1,11 +0,0 @@
// 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) }),
};
-22
View File
@@ -1,22 +0,0 @@
// 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);
}
}
-24
View File
@@ -1,24 +0,0 @@
// Author: fruit.com.cn
const FALLBACK_IMAGE = '/assets/hero.jpg';
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '');
const getText = (value, lang = 'id') => (typeof value === 'string' ? value : value?.[lang] || value?.en || value?.id || value?.zh || '');
const resolveMedia = (value) => value ? (/^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`) : FALLBACK_IMAGE;
const getImage = (item) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
export function normalizeDrama(item, lang = 'id') {
const id = String(item.id ?? item.dramaId ?? item.code ?? '');
const episodes = item.episodes || item.episodeList || (item.preview ? [item.preview] : []);
const preview = item.preview || episodes[0] || null;
return { id, title: getText(item.titleLocales || 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) || getText(item.types?.[0], lang), image: getImage(item), duration: item.duration || preview?.duration || '', views: item.views || item.viewCount || '', episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null };
}
export function normalizePage(payload) {
if (Array.isArray(payload)) return { total: payload.length, list: payload };
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
}
export function resolvePlaybackUrl(value) { return resolveMedia(value); }
export function uniqueDramas(items) { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); }
export function selectPlaybackUrl(playback, fallback = '') { const tracks = playback?.tracks || []; const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0]; return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback); }
+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import { API_CONFIG } from './config';
import { ApiError, CanceledError, NetworkError, TimeoutError } from './errors';
function mergeHeaders(base, extra) {
const headers = { ...base, ...extra };
Object.keys(headers).forEach((key) => {
if (headers[key] === undefined || headers[key] === null) delete headers[key];
});
return headers;
}
async function runInterceptors(interceptors, value) {
let result = value;
for (const interceptor of interceptors) result = await interceptor(result);
return result;
}
export function createHttpClient(overrides = {}) {
const config = {
...API_CONFIG,
...overrides,
headers: mergeHeaders(API_CONFIG.headers, overrides.headers),
};
const requestInterceptors = [];
const responseInterceptors = [];
const errorInterceptors = [];
const client = {
config,
useRequest(interceptor) {
requestInterceptors.push(interceptor);
return client;
},
useResponse(interceptor) {
responseInterceptors.push(interceptor);
return client;
},
useError(interceptor) {
errorInterceptors.push(interceptor);
return client;
},
async request(path, options = {}) {
const { signal: externalSignal, body, ...rest } = options;
const requestConfig = await runInterceptors(requestInterceptors, {
method: 'GET',
...rest,
path,
body,
headers: mergeHeaders(config.headers, rest.headers),
});
const controller = new AbortController();
let timedOut = false;
const timer = globalThis.setTimeout(() => {
timedOut = true;
controller.abort();
}, config.timeout);
const abortFromOutside = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', abortFromOutside);
}
try {
const response = await fetch(`${config.baseUrl}${requestConfig.path}`, {
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.body,
signal: controller.signal,
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
throw new ApiError(payload?.message || `Request failed (${response.status})`, {
status: response.status,
code: payload?.code ?? null,
path: requestConfig.path,
});
}
if (payload && typeof payload === 'object' && 'code' in payload && !config.successCodes.includes(payload.code)) {
throw new ApiError(payload.message || 'Request failed', {
status: response.status,
code: payload.code,
path: requestConfig.path,
});
}
const data = payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload;
return await runInterceptors(responseInterceptors, data);
} catch (caught) {
let error = caught;
if (caught?.name === 'AbortError') {
error = timedOut || !externalSignal?.aborted
? new TimeoutError(requestConfig.path, config.timeout)
: new CanceledError(requestConfig.path);
} else if (!(caught instanceof ApiError)) {
error = new NetworkError(requestConfig.path, caught);
}
const normalized = await runInterceptors(errorInterceptors, error);
throw normalized;
} finally {
globalThis.clearTimeout(timer);
externalSignal?.removeEventListener?.('abort', abortFromOutside);
}
},
get(path, options = {}) {
return client.request(path, { ...options, method: 'GET' });
},
post(path, body, options = {}) {
return client.request(path, {
...options,
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
});
},
delete(path, options = {}) {
return client.request(path, { ...options, method: 'DELETE' });
},
};
return client;
}
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
export const API_CONFIG = {
baseUrl: (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, ''),
timeout: 12000,
successCodes: [0, 200],
headers: {
Accept: 'application/json',
'X-Client-Platform': 'web',
},
};
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
import { http } from './index';
import { toQuery } from './query';
export const engagementApi = {
toggleLike: (targetType, targetId, options) => http.post('/app/likes/toggle', { targetType, targetId }, options),
toggleFavorite: (dramaId, options) => http.post('/app/favorites/toggle', { dramaId }, options),
comments: ({ dramaId, episodeId, size = 30 }, options) => http.get(`/app/comments${toQuery({ dramaId, episodeId, size })}`, options),
publishComment: ({ dramaId, episodeId, content }, options) => http.post('/app/comments', { dramaId, ...(episodeId ? { episodeId } : {}), content }, options),
};
+36
View File
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
export class ApiError extends Error {
constructor(message, { status = 0, code = null, path = '', cause } = {}) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.path = path;
this.cause = cause;
}
}
export class TimeoutError extends ApiError {
constructor(path, timeout) {
super(`Request timed out after ${timeout}ms`, { path });
this.name = 'TimeoutError';
}
}
export class NetworkError extends ApiError {
constructor(path, cause) {
super('Network request failed', { path, cause });
this.name = 'NetworkError';
}
}
export class CanceledError extends ApiError {
constructor(path) {
super('Request canceled', { path });
this.name = 'CanceledError';
}
}
export function isCanceled(error) {
return error instanceof CanceledError || error?.name === 'CanceledError' || error?.name === 'AbortError';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import { createHttpClient } from './client';
import { attachDefaultInterceptors } from './interceptors';
export { API_CONFIG } from './config';
export { createHttpClient } from './client';
export { ApiError, CanceledError, NetworkError, TimeoutError, isCanceled } from './errors';
export const http = attachDefaultInterceptors(createHttpClient());
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { getDeviceId } from '../lib/device';
export function attachDefaultInterceptors(client) {
client.useRequest((request) => ({
...request,
headers: {
'X-Device-Id': getDeviceId(),
...(request.body ? { 'Content-Type': 'application/json' } : {}),
...request.headers,
},
}));
if (import.meta.env.DEV) {
client.useError((error) => {
console.warn(`[api] ${error.name} ${error.path}`, error.message);
return error;
});
}
return client;
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
export function toQuery(params = {}) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
});
const query = search.toString();
return query ? `?${query}` : '';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
import { http } from './index';
import { toQuery } from './query';
export const watchApi = {
report: (body, options) => http.post('/app/watch/progress', body, options),
recent: (pageIndex = 1, pageSize = 20, options) => http.get(`/app/watch/recent${toQuery({ pageIndex, pageSize })}`, options),
clear: (options) => http.delete('/app/watch/progress', options),
};
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
export function EmptyState({ message }) {
return <div className="api-note">{message}</div>;
}
export function ErrorState({ message }) {
return <div className="api-note warning">{message}</div>;
}
/** 统一 loading / error / empty 三态渲染。 */
export function AsyncBoundary({ loading, error, empty, skeleton = null, errorMessage, emptyMessage, children }) {
if (loading) return skeleton;
if (error) return <ErrorState message={errorMessage || error.message} />;
if (empty) return <EmptyState message={emptyMessage} />;
return children;
}
+81
View File
@@ -0,0 +1,81 @@
// Author: fruit.com.cn
import { useEffect, useState } from 'react';
import { Send, X } from 'lucide-react';
import { engagementApi } from '../api/engagement';
function formatTime(value, htmlLang) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString(htmlLang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function CommentsSheet({ video, locale, onClose, onPosted }) {
const [comments, setComments] = useState(null);
const [error, setError] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const loadComments = () => engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
.then((payload) => setComments(payload?.list || []))
.catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); });
useEffect(() => {
let active = true;
setComments(null);
setError('');
engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
.then((payload) => active && setComments(payload?.list || []))
.catch((loadError) => active && setError(loadError.message || 'Request failed'));
return () => { active = false; };
}, [video.id, video.preview?.id]);
const submit = async (event) => {
event.preventDefault();
const content = text.trim();
if (!content || sending) return;
setSending(true);
setError('');
try {
await engagementApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
setText('');
await loadComments();
onPosted?.();
} catch (postError) {
setError(postError.message || 'Request failed');
} finally {
setSending(false);
}
};
return (
<>
<button className="comments-backdrop" onClick={onClose} aria-label="Close comments" type="button" />
<section className="comments-sheet" role="dialog" aria-label={locale.comments}>
<header className="comments-header">
<h2>{locale.comments}</h2>
<button onClick={onClose} aria-label="Close" type="button"><X size={20} /></button>
</header>
<div className="comments-list">
{comments === null && <p className="comments-note">{locale.loading}</p>}
{error && <p className="comments-note warning">{error}</p>}
{comments && !comments.length && !error && <p className="comments-note">{locale.noComments}</p>}
{comments?.map((comment) => (
<div className="comment-row" key={comment.id}>
<span className="comment-avatar">{comment.nickname?.[0] || 'Y'}</span>
<div className="comment-body">
<b>{comment.nickname || 'Guest'}</b>
<p>{comment.content}</p>
<small>{formatTime(comment.createdAt, locale.htmlLang)}</small>
</div>
</div>
))}
</div>
<form className="comment-form" onSubmit={submit}>
<input value={text} onChange={(event) => setText(event.target.value)} placeholder={locale.commentPlaceholder} aria-label={locale.commentPlaceholder} />
<button type="submit" disabled={!text.trim() || sending} aria-label={locale.send}><Send size={18} /></button>
</form>
</section>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
import { useLayoutEffect, useRef } from 'react';
const DURATION = 420;
const FADE = 180;
export function HeroTransition({ data, onDone }) {
const ref = useRef(null);
useLayoutEffect(() => {
if (!data) return undefined;
const node = ref.current;
if (!node) return undefined;
const rect = data;
const scale = Math.max(window.innerWidth / rect.width, window.innerHeight / rect.height) * 1.04;
const tx = (window.innerWidth - rect.width * scale) / 2 - rect.left;
const ty = (window.innerHeight - rect.height * scale) / 2 - rect.top;
const expand = node.animate(
[
{ transform: 'translate(0, 0) scale(1)', borderRadius: '12px' },
{ transform: `translate(${tx}px, ${ty}px) scale(${scale})`, borderRadius: '0px' },
],
{ duration: DURATION, easing: 'cubic-bezier(.2, .7, .3, 1)', fill: 'forwards' },
);
expand.onfinish = () => {
const fade = node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: FADE, fill: 'forwards' });
fade.onfinish = onDone;
};
return () => expand.cancel();
}, [data, onDone]);
if (!data) return null;
const rect = data;
return (
<div
ref={ref}
className="hero-transition"
aria-hidden="true"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
>
{data.src && <img src={data.src} alt="" />}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { daysAgoLabel, formatViews } from '../lib/format';
import { setHeroTransition } from '../lib/heroTransition';
export function MediaCard({ video, compact = false, to }) {
const { locale } = useLocale();
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
const target = to || `/watch/${video.id}`;
const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · ');
const rememberRect = (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const thumb = event.currentTarget.querySelector('.media-thumb');
const img = thumb?.querySelector('img');
if (!thumb || !img) return;
const rect = thumb.getBoundingClientRect();
setHeroTransition({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, src: img.currentSrc || img.src });
};
return <article className={`media-card ${compact ? 'compact' : ''}`} onClickCapture={rememberRect}><Link to={target} 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>{compact ? <Link to={target} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={target} className="media-title">{video.title}</Link><small>{video.channel}<br />{meta}</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
}
+2
View File
@@ -0,0 +1,2 @@
// Author: fruit.com.cn
export function SectionHeading({ title, action = null }) { return <div className="section-heading"><h2>{title}</h2>{action && <button>{action}</button>}</div>; }
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
import { MediaCard } from './MediaCard';
export function VideoGrid({ items = [] }) {
return (
<div className="video-grid">
{items.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
);
}
@@ -0,0 +1,413 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
import { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react';
const CONTROLS_HIDE_DELAY = 2600;
const SOUND_KEY = 'ymb.player.sound';
function readSoundPreference() {
try {
return window.localStorage.getItem(SOUND_KEY) === '1';
} catch {
return false;
}
}
// 跨分集/切视频保持用户的音量偏好:一旦开过声音,后续自动播放也带声音
let soundEnabled = readSoundPreference();
function persistSoundPreference(value) {
soundEnabled = value;
try {
window.localStorage.setItem(SOUND_KEY, value ? '1' : '0');
} catch {
// 隐私模式下仅保持本次会话生效
}
}
// 路由切换/组件卸载时彻底停掉解码与音频,避免离开页面后仍有声音
function stopVideo(video) {
if (!video) return;
try {
video.pause();
video.muted = true;
video.removeAttribute('src');
video.load();
} catch {
// 媒体已销毁时忽略
}
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, soundHint = 'Tap for sound', onProgress, onEnded }) {
const wrapRef = useRef(null);
const videoRef = useRef(null);
const hideTimerRef = useRef(null);
const draggingRef = useRef(false);
const playedRef = useRef(false);
const onProgressRef = useRef(onProgress);
const [ready, setReady] = useState(false);
const [error, setError] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const [playing, setPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(() => (soundEnabled ? false : muted));
const [soundBlocked, setSoundBlocked] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [controlsVisible, setControlsVisible] = useState(true);
const [volumeOpen, setVolumeOpen] = useState(false);
const [hoverRatio, setHoverRatio] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
onProgressRef.current = onProgress;
}, [onProgress]);
// 暂停/离开页面时立即上报一次进度
useEffect(() => {
const video = videoRef.current;
if (!video) return undefined;
const flush = () => {
if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true);
};
// 关闭标签/切到后台时立即停止播放,避免被浏览器接管为画中画或后台音频
const onPageHide = () => {
flush();
video.pause();
};
const onVisibilityChange = () => {
if (document.hidden) video.pause();
};
video.addEventListener('pause', flush);
window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
flush();
video.removeEventListener('pause', flush);
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video || !src) return undefined;
playedRef.current = false;
setReady(false);
setError('');
setPlaying(false);
setSoundBlocked(false);
setCurrentTime(0);
setBuffered(0);
const hls = new Hls({ enableWorker: true });
const start = () => setReady(true);
const onHlsError = (_event, data) => {
if (!data?.fatal) return;
setError(`${data.details || 'Video stream error'} · ${data.response?.code || ''}`);
console.error('YMB HLS ERROR', data);
};
const onVideoError = () => {
const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`;
console.error(message, { src, mediaError: video.error });
setError(message);
};
hls.on(Hls.Events.MANIFEST_PARSED, start);
hls.on(Hls.Events.ERROR, onHlsError);
video.addEventListener('error', onVideoError);
if (Hls.isSupported()) {
hls.attachMedia(video);
hls.loadSource(src);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src;
video.addEventListener('loadedmetadata', start, { once: true });
} else {
setError('This browser does not support HLS');
}
return () => {
video.removeEventListener('error', onVideoError);
hls.destroy();
stopVideo(video);
};
}, [src, reloadKey]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const nextMuted = muted && !soundEnabled;
video.muted = nextMuted;
setIsMuted(nextMuted);
}, [muted]);
useEffect(() => {
const video = videoRef.current;
if (!video || !ready) return;
if (!autoPlay) {
video.pause();
return;
}
const wantSound = soundEnabled;
video.muted = !wantSound;
setIsMuted(!wantSound);
video.play().catch((playError) => {
if (playError.name === 'AbortError') return;
// 浏览器拦截带声音的自动播放时,降级为静音播放并提示用户手动开启
if (playError.name === 'NotAllowedError' && wantSound) {
video.muted = true;
setIsMuted(true);
setSoundBlocked(true);
video.play().catch(() => {});
return;
}
setError(`PLAY_FAILED: ${playError.message}`);
});
}, [autoPlay, ready]);
useEffect(() => {
const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
useEffect(() => {
const video = videoRef.current;
return () => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
stopVideo(video);
};
}, []);
const revealControls = () => {
setControlsVisible(true);
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = window.setTimeout(() => {
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false);
}, CONTROLS_HIDE_DELAY);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.muted = false;
setIsMuted(false);
persistSoundPreference(true);
video.play().catch((playError) => {
if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`);
});
} else {
video.pause();
}
revealControls();
};
const seekToRatio = (ratio) => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration)) return;
video.currentTime = Math.min(Math.max(ratio, 0), 1) * video.duration;
};
const seekFromPointer = (event) => {
const rect = event.currentTarget.getBoundingClientRect();
if (!rect.width) return;
seekToRatio((event.clientX - rect.left) / rect.width);
};
const enableSound = () => {
const video = videoRef.current;
if (!video) return;
video.muted = false;
setIsMuted(false);
setSoundBlocked(false);
persistSoundPreference(true);
video.play().catch(() => {});
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
persistSoundPreference(!video.muted);
};
const changeVolume = (event) => {
const next = Number(event.target.value);
const video = videoRef.current;
setVolume(next);
if (!video) return;
video.volume = next;
video.muted = next === 0;
setIsMuted(next === 0);
persistSoundPreference(next > 0);
};
const toggleFullscreen = async () => {
const node = wrapRef.current;
if (!node) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await node.requestFullscreen();
} catch {
// 全屏被浏览器拒绝时保持当前状态
}
};
const onKeyDown = (event) => {
const video = videoRef.current;
if (!video) return;
if (event.key === ' ' || event.key.toLowerCase() === 'k') {
event.preventDefault();
togglePlay();
} else if (event.key === 'ArrowLeft') {
video.currentTime = Math.max(0, video.currentTime - 5);
} else if (event.key === 'ArrowRight') {
video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
} else if (event.key.toLowerCase() === 'm') {
toggleMute();
} else if (event.key.toLowerCase() === 'f') {
toggleFullscreen();
}
revealControls();
};
const percent = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
const bufferedPercent = duration > 0 ? Math.min(100, (buffered / duration) * 100) : 0;
return (
<div
ref={wrapRef}
className={`vp video-player-wrap ${controlsVisible ? 'show-controls' : ''} ${playing ? 'is-playing' : ''}`}
onMouseMove={revealControls}
onMouseLeave={() => playing && setControlsVisible(false)}
onKeyDown={onKeyDown}
tabIndex={0}
role="group"
aria-label="Video player"
>
<video
ref={videoRef}
className="video-player"
poster={poster}
playsInline
disablePictureInPicture
disableRemotePlayback
muted={muted}
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onPlay={() => { setPlaying(true); revealControls(); }}
onPause={() => { setPlaying(false); setControlsVisible(true); }}
onWaiting={() => setReady(false)}
onPlaying={() => setReady(true)}
onEnded={onEnded}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => {
const video = event.currentTarget;
if (video.currentTime > 0) playedRef.current = true;
setCurrentTime(video.currentTime);
setDuration(video.duration || 0);
if (video.buffered.length) setBuffered(video.buffered.end(video.buffered.length - 1));
onProgressRef.current?.(video.currentTime, video.duration, false);
}}
/>
{!ready && !error && <span className="vp-spinner" aria-hidden="true" />}
{soundBlocked && !error && (
<button className="vp-sound-hint" type="button" onClick={enableSound}>
<VolumeX size={15} />
<span>{soundHint}</span>
</button>
)}
{error && (
<button
className="vp-error"
type="button"
onClick={() => { setError(''); setReloadKey((key) => key + 1); }}
>
{error} · 点击重试
</button>
)}
{ready && !playing && !error && (
<button className="vp-big-play" type="button" onClick={togglePlay} aria-label="Play">
<Play size={26} fill="currentColor" />
</button>
)}
<div className="vp-controls" onPointerDown={(event) => event.stopPropagation()}>
<div
className="vp-progress"
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
aria-label="Progress"
onPointerDown={(event) => {
draggingRef.current = true;
event.currentTarget.setPointerCapture?.(event.pointerId);
seekFromPointer(event);
}}
onPointerMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
if (rect.width) setHoverRatio(Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1));
if (draggingRef.current) seekFromPointer(event);
}}
onPointerLeave={() => setHoverRatio(null)}
onPointerUp={() => { draggingRef.current = false; }}
onPointerCancel={() => { draggingRef.current = false; }}
>
<span className="vp-progress-track">
<i className="vp-buffered" style={{ width: `${bufferedPercent}%` }} />
<i className="vp-played" style={{ width: `${percent}%` }} />
</span>
<span className="vp-thumb" style={{ left: `${percent}%` }} />
{hoverRatio !== null && duration > 0 && (
<span className="vp-progress-tip" style={{ left: `${hoverRatio * 100}%` }}>
{formatTime(hoverRatio * duration)}
</span>
)}
</div>
<div className="vp-row">
<button className="vp-btn" type="button" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
{playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<span className="vp-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<span className="vp-spacer" />
<div className="vp-volume" onMouseEnter={() => setVolumeOpen(true)} onMouseLeave={() => setVolumeOpen(false)}>
<button className="vp-btn" type="button" onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}>
{isMuted ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button>
<span className={`vp-volume-slider ${volumeOpen ? 'open' : ''}`}>
<input
type="range"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
onChange={changeVolume}
aria-label="Volume"
/>
</span>
</div>
<button className="vp-btn" type="button" onClick={toggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
function Skeleton({ className = '' }) {
return <span className={`skeleton ${className}`.trim()} aria-hidden="true" />;
}
export function MediaCardSkeleton({ compact = false }) {
return (
<article className="media-card skeleton-card" aria-hidden="true">
<Skeleton className={`skeleton-thumb ${compact ? 'compact' : ''}`} />
{compact ? <Skeleton className="skeleton-short-title" /> : (
<div className="skeleton-meta">
<Skeleton className="skeleton-avatar" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
)}
</article>
);
}
export function VideoGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} />)}
</div>
);
}
export function ShortsShelfSkeleton({ count = 5, label = 'Loading' }) {
return (
<div className="shorts-row" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} compact />)}
</div>
);
}
export function CategoryChipsSkeleton() {
return (
<div className="youtube-category-bar" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => <Skeleton key={index} className="skeleton-chip" />)}
</div>
);
}
export function HistoryGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => (
<article className="skeleton-card history-skeleton" key={index} aria-hidden="true">
<Skeleton className="skeleton-thumb" />
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</article>
))}
</div>
);
}
export function ShortsSkeleton({ label = 'Loading' }) {
return (
<div className="shorts-skeleton" aria-busy="true" aria-label={label}>
<div className="shorts-skeleton-wrap">
<Skeleton className="shorts-skeleton-screen" />
<div className="shorts-skeleton-rail" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="skeleton-dot" />)}
</div>
<div className="shorts-skeleton-copy" aria-hidden="true">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
</div>
);
}
export function WatchPageSkeleton({ label = 'Loading' }) {
return (
<div className="watch-page" aria-busy="true" aria-label={label}>
<div className="watch-primary">
<Skeleton className="watch-skeleton-player" />
<Skeleton className="watch-skeleton-title" />
<div className="watch-skeleton-row">
<Skeleton className="skeleton-avatar" />
<Skeleton className="watch-skeleton-channel" />
</div>
<Skeleton className="watch-skeleton-description" />
</div>
<aside className="watch-side">
<Skeleton className="watch-skeleton-heading" />
{Array.from({ length: 4 }, (_, index) => (
<div className="watch-side-card" key={index} aria-hidden="true">
<Skeleton className="watch-side-thumb" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
))}
</aside>
</div>
);
}
File renamed without changes.
File renamed without changes.
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
export const COMPANY = {
legalName: 'PT YMB TECHNOLOGY INDONESIA',
address: 'Komplek Ruko Mega Grosir Cempaka Mas Blok C No. 6, Jakarta Pusat 10640, Indonesia',
phone: '+62 821 7000 5974',
phoneHref: '+6282170005974',
email: 'ymbtechnologyindonesia@gmail.com',
nib: '1240000631056',
};
+56
View File
@@ -0,0 +1,56 @@
// Author: fruit.com.cn
import { useState } from 'react';
import { engagementApi } from '../api/engagement';
import { useMutation } from './useMutation';
/**
* 点赞 / 收藏状态与交互,Shorts 与播放页共用。
* likeTargetType: 'EPISODE' | 'DRAMA'
*/
export function useEngagement({ likeTargetType, likeTargetId, dramaId, initialLikeCount = 0, initialSaveCount = 0 }) {
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(Number(initialLikeCount) || 0);
const [saved, setSaved] = useState(false);
const [saveCount, setSaveCount] = useState(Number(initialSaveCount) || 0);
const likeMutation = useMutation(() => engagementApi.toggleLike(likeTargetType, likeTargetId));
const saveMutation = useMutation(() => engagementApi.toggleFavorite(dramaId));
const toggleLike = async () => {
try {
const result = await likeMutation.run();
setLiked(Boolean(result?.liked));
setLikeCount(Number(result?.count) || 0);
} catch {
// 交互失败时保持原状态,避免误导用户
}
};
const toggleSave = async () => {
try {
const next = await saveMutation.run();
setSaved(Boolean(next));
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
} catch {
// 同上
}
};
const syncStats = (stats) => {
if (!stats) return;
setLiked(Boolean(stats.liked));
setLikeCount(Number(stats.likeCount) || 0);
setSaved(Boolean(stats.favorited));
setSaveCount(Number(stats.favoriteCount) || 0);
};
return {
liked,
likeCount,
saved,
saveCount,
toggleLike,
toggleSave,
syncStats,
pending: likeMutation.pending || saveMutation.pending,
};
}
+29
View File
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import { useState } from 'react';
const FOLLOW_KEY = 'ymb.shorts.following';
function readFollowedChannels() {
try {
const stored = JSON.parse(window.localStorage.getItem(FOLLOW_KEY) || '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
}
export function useFollowedChannels() {
const [channels, setChannels] = useState(readFollowedChannels);
const toggle = (channel) => {
setChannels((prev) => {
const next = prev.includes(channel) ? prev.filter((item) => item !== channel) : [...prev, channel];
try {
window.localStorage.setItem(FOLLOW_KEY, JSON.stringify(next));
} catch (error) {
console.error('YMB FOLLOW STORAGE FAILED', error);
}
return next;
});
};
return { channels, toggle };
}
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import { useCallback, useRef, useState } from 'react';
/** 统一写操作 hook:暴露 pending / error,run 失败会抛错由调用方决定提示方式。 */
export function useMutation(mutator) {
const [state, setState] = useState({ pending: false, error: null });
const mutatorRef = useRef(mutator);
mutatorRef.current = mutator;
const run = useCallback(async (...args) => {
setState({ pending: true, error: null });
try {
const data = await mutatorRef.current(...args);
setState({ pending: false, error: null });
return data;
} catch (error) {
setState({ pending: false, error });
throw error;
}
}, []);
return { ...state, run };
}
+41
View File
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
import { isCanceled } from '../api';
/**
* 统一查询 hook:支持取消、错误对象、重试(refetch)与禁用。
* fetcher 接收 { signal },应把 signal 透传给 api 方法。
*/
export function useRequest(fetcher, deps = [], { enabled = true, initialData = null } = {}) {
const [reloadKey, setReloadKey] = useState(0);
const [state, setState] = useState({ data: initialData, loading: enabled, error: null });
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
useEffect(() => {
if (!enabled) {
setState({ data: initialData, loading: false, error: null });
return undefined;
}
const controller = new AbortController();
let active = true;
setState((prev) => ({ ...prev, loading: true, error: null }));
Promise.resolve()
.then(() => fetcherRef.current({ signal: controller.signal }))
.then((data) => {
if (active) setState({ data, loading: false, error: null });
})
.catch((error) => {
if (!active || isCanceled(error)) return;
setState({ data: initialData, loading: false, error });
});
return () => {
active = false;
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, enabled, reloadKey]);
const refetch = useCallback(() => setReloadKey((key) => key + 1), []);
return { ...state, refetch };
}
+24
View File
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
export function useToast(duration = 2000) {
const [message, setMessage] = useState('');
const timerRef = useRef(null);
const show = useCallback((text) => {
setMessage(text);
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setMessage(''), duration);
}, [duration]);
const hide = useCallback(() => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setMessage('');
}, []);
useEffect(() => () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
}, []);
return { message, show, hide };
}
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
const DEVICE_KEY = 'ymb.device.id';
function createDeviceId() {
const uuid = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
return `d-${uuid}`;
}
let cachedDeviceId = '';
export function getDeviceId() {
if (cachedDeviceId) return cachedDeviceId;
try {
const stored = window.localStorage.getItem(DEVICE_KEY);
cachedDeviceId = stored || createDeviceId();
if (!stored) window.localStorage.setItem(DEVICE_KEY, cachedDeviceId);
} catch {
cachedDeviceId = createDeviceId();
}
return cachedDeviceId;
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
const UNITS = {
id: [[1e9, 'M'], [1e6, 'jt'], [1e3, 'rb']],
en: [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
zh: [[1e8, '亿'], [1e4, '万']],
};
const DAYS_AGO = { id: '2 hari lalu', en: '2 days ago', zh: '2天前' };
const VIEW_LABEL = { id: 'tayangan', en: 'views', zh: '次观看' };
function formatDecimal(value, code) {
const rounded = value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
return code === 'id' ? rounded.replace('.', ',') : rounded;
}
export function formatCount(value, code = 'id') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value == null ? '' : String(value);
for (const [size, unit] of UNITS[code] || UNITS.id) {
if (numeric >= size) {
const separator = code === 'id' ? ' ' : '';
return `${formatDecimal(numeric / size, code)}${separator}${unit}`;
}
}
return String(Math.round(numeric));
}
export function formatViews(value, code = 'id') {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string' && !Number.isFinite(Number(value))) return value;
const count = formatCount(value, code);
if (!count) return '';
const label = VIEW_LABEL[code] || VIEW_LABEL.id;
return code === 'zh' ? `${count}${label}` : `${count} ${label}`;
}
export function daysAgoLabel(code = 'id') {
return DAYS_AGO[code] || DAYS_AGO.id;
}
+24
View File
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
const GEO_ENDPOINT = 'https://ipwho.is/?fields=country_code';
const GEO_TIMEOUT_MS = 4000;
const CHINESE_REGIONS = ['CN', 'TW', 'HK', 'MO'];
/** 根据 IP 归属地推断站点语言;失败返回空字符串,由调用方回退。 */
export async function detectLocaleFromIp() {
const controller = new AbortController();
const timer = globalThis.setTimeout(() => controller.abort(), GEO_TIMEOUT_MS);
try {
const response = await fetch(GEO_ENDPOINT, { signal: controller.signal });
if (!response.ok) return '';
const payload = await response.json();
const country = typeof payload?.country_code === 'string' ? payload.country_code.toUpperCase() : '';
if (!country) return '';
if (country === 'ID') return 'id';
if (CHINESE_REGIONS.includes(country)) return 'zh';
return 'en';
} catch {
return '';
} finally {
globalThis.clearTimeout(timer);
}
}
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
// 记录用户点击卡片的封面与位置,用于进入播放页时的放大过渡
let pending = null;
export function setHeroTransition(data) {
pending = data;
}
export function getHeroTransition() {
const data = pending;
if (!data) return null;
window.setTimeout(() => {
if (pending === data) pending = null;
}, 1200);
return data;
}
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
export const FALLBACK_IMAGE = '/assets/hero.jpg';
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '');
export const resolveMedia = (value) => {
if (!value) return FALLBACK_IMAGE;
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
};
export function resolvePlaybackUrl(value) {
if (!value) return '';
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
}
export const getImage = (item = {}) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
export function selectPlaybackUrl(playback, fallback = '') {
const tracks = playback?.tracks || [];
const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0];
return resolvePlaybackUrl(preferred?.playUrl || playback?.playUrl || fallback);
}
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
export function normalizePage(payload) {
if (Array.isArray(payload)) return { total: payload.length, list: payload };
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more