diff --git a/README.md b/README.md index 2584819..9c401ed 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,22 @@ 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 / BottomNav +└── i18n/ # ID / EN / zh-CN 语言包 ``` +依赖方向:`features → entities → shared`,`layouts → shared`;`shared` 不允许反向依赖业务域。 + ## YouTube 对照的信息架构 - 顶部 AppBar:品牌、全局搜索、语言、通知、用户入口。 @@ -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`。 diff --git a/app.js b/app.js deleted file mode 100644 index 75fb844..0000000 --- a/app.js +++ /dev/null @@ -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)=>`${g}`).join('');$('#filter-chips').innerHTML=genres.map((g)=>``).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)=>`
${v[0]}${v[4]}
${v[1][0]}

${v[0]}

${v[1]}
${(i+4)*137} rb tayangan · ${i+1} hari lalu

`).join('')||`

${locale.empty}

`;$('#shorts-row').innerHTML=d.shorts.map((v)=>`
${v[0]}${v[0]}
`).join('');$('#channel-grid').innerHTML=d.channels.map((v)=>`
${v[2]}

${v[0]}

${v[1]}
`).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()})(); diff --git a/data.js b/data.js deleted file mode 100644 index 787280d..0000000 --- a/data.js +++ /dev/null @@ -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']] -}; diff --git a/docs/plans/2026-11-12-enterprise-refactor.md b/docs/plans/2026-11-12-enterprise-refactor.md new file mode 100644 index 0000000..286ca83 --- /dev/null +++ b/docs/plans/2026-11-12-enterprise-refactor.md @@ -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 +} + emptyText={locale.empty} + onRetry={request.refetch} +> + + +``` + +## 五、分阶段实施 + +| 阶段 | 内容 | 验收 | +| --- | --- | --- | +| 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 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。 +- 目录符合本文第二节,跨层依赖检查无违规。 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3fefdc5 --- /dev/null +++ b/eslint.config.js @@ -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 } }, + }, +]; diff --git a/friends.jpg b/friends.jpg deleted file mode 100644 index c8a06d1..0000000 Binary files a/friends.jpg and /dev/null differ diff --git a/heir.jpg b/heir.jpg deleted file mode 100644 index eb9208c..0000000 Binary files a/heir.jpg and /dev/null differ diff --git a/hero.jpg b/hero.jpg deleted file mode 100644 index d397b1f..0000000 Binary files a/hero.jpg and /dev/null differ diff --git a/package.json b/package.json index 4efaa2e..7b1865e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acea96f..69fa2db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,166 @@ importers: vite: specifier: latest version: 8.3.7 + devDependencies: + '@eslint/js': + specifier: ^9.39.5 + version: 9.39.5 + eslint: + specifier: ^9.39.5 + version: 9.39.5 + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.5) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@9.39.5) + globals: + specifier: ^17.14.0 + version: 17.14.0 packages: + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.13': + resolution: {integrity: sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.14': + resolution: {integrity: sha512-qSY56jgW5JnSXzuekLVil6zaOQxOCtOHidr65KkaMBw772IuAY28Qx4ZykAcDn8+FRF15qk80XzKwgSc/RYW6g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.13': + resolution: {integrity: sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.14': + resolution: {integrity: sha512-2XDvQVd9JESaOgGc4jT2/jquTAO7ppA5noTZQqH+MmZ89yjCzScLwtCPbWtkPZylL+TLWR49IwWmk+QkRR4GsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.14': + resolution: {integrity: sha512-CXvKMFvM80ihI7Vox9jb+kQjnyjESQNPM+vB7U0Zz6TWuZA+Z/FL0QPMH300h33qJtIx+REeBhXqWOPW48TcAQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.14': + resolution: {integrity: sha512-6E3Mi1r0B0umq6vmZa4/78q+64fqO2YY0THUdjDPKUCKhNb0yQy5NQIaPHCSD3ryTgiTss//P4JsWS80AsZ6LQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.14': + resolution: {integrity: sha512-VL220vXuyO2z3rNIQwY7ftj9UwpqOt+33sH+sBWkOekbsSR2g7EEMnOx24ZlCH0xOA3DM/33SifVFGbnJD8r/w==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.14': + resolution: {integrity: sha512-RAdQJrBgmIH1FWvN0X+1hoDxzMzlP4cv/1Zi4dn4tG1H0cTb+rw7CzUxQJhp1f4ANd8j5vH3fy8BAWFGkBDEsA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.14': + resolution: {integrity: sha512-UjI/dKmwJwT9pOszJitq2G6EObZPnsZUfdCr92z2TlF3EZK9J3EdfOKUrCzHl6wFthbzwhzl1Soc/UIwxRiq6w==} + engines: {node: '>=6.9.0'} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.3': + resolution: {integrity: sha512-R76UKbvsqI68DJJVTVyqFKqU98CN4+B+acxNfEA2IFarSzP0SjtKf/3+fFiXzTyv5Fj3PxLkGHe2wnVWWMxobQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@oxc-project/types@0.156.0': resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} @@ -128,6 +285,12 @@ packages: '@rolldown/pluginutils@1.1.0': resolution: {integrity: sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==} + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@vitejs/plugin-react@6.3.0': resolution: {integrity: sha512-H9E955aVvD2F4zNHCw4eIZbIfF6xKldlh7MNu+hSU7xwQzh65lI/N+CJWqmfhi0PEjDXIt/H7T7J/4wU63KJiw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -144,14 +307,277 @@ packages: oxc-transform-react: optional: true + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.11.36: + resolution: {integrity: sha512-AbPFGS4K9S3meMRCLTM0Zas+e9X7b5ygUBOboDT5zevCZt9ByKSbMtKhwyVqW+oL8WemPVsbP7X3rdZcIyOa0w==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.22: + resolution: {integrity: sha512-CKLDTuSsN0+r1ThM0WkzzBI7dnPI6xEGfa/JnVYIK9JNBA1N1NyPwXVHor//NKD/y3sDxp764sZdLhFK7pH8XQ==} + + browserslist@4.29.5: + resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001826: + resolution: {integrity: sha512-wTB+9tIbLro/BCXwLuNV1meHE4L9dS6dArMDwgYfNBPMYScqClHyGunhkCSDtHhe/69Xl0sHlKgBVZ+fNsYtWA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.481: + resolution: {integrity: sha512-dNUsbAP6E2S1dHIYEuN3cQ0dViThjMtpVRheGdvv5HgHFtzLi4Cu4Mbzms85YggP3Eh2VVZTl5+XxEN8ofCbmQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -161,14 +587,279 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.14.0: + resolution: {integrity: sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hls.js@1.7.4: resolution: {integrity: sha512-3GlxGdPvT2YHvQYOt+yOzT/DEior/qJVNIylHA77kgeoBTdQQqi70GwefNEUmHQeNGyjC8U62+bruqW/Tpm+tg==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.18.0: + resolution: {integrity: sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -239,6 +930,20 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.71.0: resolution: {integrity: sha512-8YtvF+bqT/EMqAXU7kWvYdRu+TV9j4kS1eIS7IbN1j1Z1DgupbrTL0nDIygdqgBDTD7AJPP607VdVgzDTar4zA==} peerDependencies: @@ -248,11 +953,91 @@ packages: '@types/react': optional: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + + node-releases@2.0.63: + resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -260,15 +1045,33 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.32: resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@19.3.1: resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} peerDependencies: react: ^19.3.1 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-router-dom@7.18.5: resolution: {integrity: sha512-9HcfDMU6JJ/fp6gJZYGwNE6RknVGS4kBjH1HkNw959mCJi70TVwdipQWv2FEol9uGpi+SZTFtVpJPtEQfayeOw==} engines: {node: '>=20.0.0'} @@ -290,25 +1093,162 @@ packages: resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} engines: {node: '>=0.10.0'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown@1.2.18: resolution: {integrity: sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + semver@6.3.2: + resolution: {integrity: sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==} + hasBin: true + set-cookie-parser@2.7.3: resolution: {integrity: sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.3.7: resolution: {integrity: sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==} engines: {node: ^20.19.0 || >=22.12.0} @@ -352,8 +1292,230 @@ packages: yaml: optional: true + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + snapshots: + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.13': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.14 + '@babel/helper-compilation-targets': 7.29.13 + '@babel/helper-module-transforms': 7.29.14(@babel/core@7.29.13) + '@babel/helpers': 7.29.14 + '@babel/parser': 7.29.14 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.14 + '@babel/types': 7.29.14 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.2 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.14': + dependencies: + '@babel/parser': 7.29.14 + '@babel/types': 7.29.14 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.13': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.5 + lru-cache: 5.1.1 + semver: 6.3.2 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.14 + '@babel/types': 7.29.14 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.14(@babel/core@7.29.13)': + dependencies: + '@babel/core': 7.29.13 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.14 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.14': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.14': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.14 + + '@babel/parser@7.29.14': + dependencies: + '@babel/types': 7.29.14 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.14 + '@babel/types': 7.29.14 + + '@babel/traverse@7.29.14': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.14 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.14 + '@babel/template': 7.29.7 + '@babel/types': 7.29.14 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.14': + dependencies: + '@babel/helper-string-parser': 7.29.14 + '@babel/helper-validator-identifier': 7.29.7 + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.3': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.7': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@oxc-project/types@0.156.0': {} '@rolldown/binding-android-arm-eabi@1.2.18': @@ -403,24 +1565,714 @@ snapshots: '@rolldown/pluginutils@1.1.0': {} + '@types/estree@1.0.10': {} + + '@types/json-schema@7.0.15': {} + '@vitejs/plugin-react@6.3.0(vite@8.3.7)': dependencies: '@rolldown/pluginutils': 1.1.0 vite: 8.3.7 + acorn-jsx@5.3.2(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + + acorn@8.19.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.11.36: {} + + brace-expansion@1.1.22: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + browserslist@4.29.5: + dependencies: + baseline-browser-mapping: 2.11.36 + caniuse-lite: 1.0.30001826 + electron-to-chromium: 1.5.481 + node-releases: 2.0.63 + update-browserslist-db: 1.3.4(browserslist@4.29.5) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001826: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + detect-libc@2.1.2: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.481: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.24 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.4.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5): + dependencies: + '@babel/core': 7.29.13 + '@babel/parser': 7.29.14 + eslint: 9.39.5 + hermes-parser: 0.25.1 + zod: 4.6.7 + zod-validation-error: 4.0.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.5): + dependencies: + array-includes: 3.2.0 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.4.0 + eslint: 9.39.5 + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.2 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.3 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.10 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.14.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + hls.js@1.7.4: {} + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.18.0: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.2.0 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.33.0: optional: true @@ -470,27 +2322,144 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lucide-react@1.71.0(react@19.3.1): dependencies: react: 19.3.1 + math-intrinsics@1.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.22 + + ms@2.1.3: {} + nanoid@3.3.20: {} + natural-compare@1.4.0: {} + + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.2 + + node-releases@2.0.63: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.32: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 source-map-js: 1.2.2 + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + react-dom@19.3.1(react@19.3.1): dependencies: react: 19.3.1 scheduler: 0.28.0 + react-is@16.13.1: {} + react-router-dom@7.18.5(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: react: 19.3.1 @@ -507,6 +2476,37 @@ snapshots: react@19.3.1: {} + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.18.0 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown@1.2.18: dependencies: '@oxc-project/types': 0.156.0 @@ -528,17 +2528,205 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.18 '@rolldown/binding-win32-x64-msvc': 1.2.18 + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + scheduler@0.28.0: {} + semver@6.3.2: {} + set-cookie-parser@2.7.3: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + source-map-js@1.2.2: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.matchall@4.1.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.5: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + update-browserslist-db@1.3.4(browserslist@4.29.5): + dependencies: + browserslist: 4.29.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + vite@8.3.7: dependencies: lightningcss: 1.33.0 @@ -548,3 +2736,60 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.24 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} diff --git a/public/brand/ymb-logo-gold.png b/public/brand/ymb-logo-gold.png deleted file mode 100644 index 406a788..0000000 Binary files a/public/brand/ymb-logo-gold.png and /dev/null differ diff --git a/robots.txt b/public/robots.txt similarity index 100% rename from robots.txt rename to public/robots.txt diff --git a/sitemap.xml b/public/sitemap.xml similarity index 100% rename from sitemap.xml rename to public/sitemap.xml diff --git a/revenge.jpg b/revenge.jpg deleted file mode 100644 index e82cb29..0000000 Binary files a/revenge.jpg and /dev/null differ diff --git a/romance.jpg b/romance.jpg deleted file mode 100644 index cc39851..0000000 Binary files a/romance.jpg and /dev/null differ diff --git a/second-chance.jpg b/second-chance.jpg deleted file mode 100644 index 55683a8..0000000 Binary files a/second-chance.jpg and /dev/null differ diff --git a/secret.jpg b/secret.jpg deleted file mode 100644 index ce53b5a..0000000 Binary files a/secret.jpg and /dev/null differ diff --git a/src/app/App.jsx b/src/app/App.jsx index d7ce5db..8ab5307 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -1,13 +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 { HistoryPage } from '../pages/HistoryPage'; -import { AboutPage } from '../pages/AboutPage'; +import { BrowserRouter } from 'react-router-dom'; +import { AppProviders } from './providers/AppProviders'; +import { AppRouter } from './router'; -export function App() { return }>} />} />} />} />} />} />} />} />; } +export function App() { + return ( + + + + + + ); +} diff --git a/src/app/providers/AppProviders.jsx b/src/app/providers/AppProviders.jsx new file mode 100644 index 0000000..cdcdeec --- /dev/null +++ b/src/app/providers/AppProviders.jsx @@ -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 ( + + {children} + + ); +} + +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; +} diff --git a/src/app/router.jsx b/src/app/router.jsx new file mode 100644 index 0000000..03b9a1d --- /dev/null +++ b/src/app/router.jsx @@ -0,0 +1,29 @@ +// 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 }))); + +export function AppRouter() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/src/app/store.jsx b/src/app/store.jsx deleted file mode 100644 index a5a94bd..0000000 --- a/src/app/store.jsx +++ /dev/null @@ -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 {children}; } -export function useAppStore() { return useContext(StoreContext); } diff --git a/src/components/player/VideoPlayer.jsx b/src/components/player/VideoPlayer.jsx deleted file mode 100644 index 0408066..0000000 --- a/src/components/player/VideoPlayer.jsx +++ /dev/null @@ -1,13 +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(''); const playedRef = useRef(false); const onProgressRef = useRef(onProgress); - useEffect(() => { onProgressRef.current = onProgress; }, [onProgress]); - useEffect(() => { const video = ref.current; if (!video) return undefined; const flush = () => { if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true); }; video.addEventListener('pause', flush); window.addEventListener('pagehide', flush); return () => { flush(); video.removeEventListener('pause', flush); window.removeEventListener('pagehide', flush); }; }, []); - useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; playedRef.current = false; 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
; -} diff --git a/src/entities/category/api.js b/src/entities/category/api.js new file mode 100644 index 0000000..83296de --- /dev/null +++ b/src/entities/category/api.js @@ -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), +}; diff --git a/src/entities/drama/api.js b/src/entities/drama/api.js new file mode 100644 index 0000000..69634c1 --- /dev/null +++ b/src/entities/drama/api.js @@ -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), +}; diff --git a/src/entities/drama/model.js b/src/entities/drama/model.js new file mode 100644 index 0000000..2c77a0f --- /dev/null +++ b/src/entities/drama/model.js @@ -0,0 +1,52 @@ +// Author: fruit.com.cn +import { getImage, resolveMedia } 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: resolveMedia(episode.playUrl) })), + preview: preview ? { ...preview, playUrl: resolveMedia(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; + }); +} diff --git a/src/pages/AboutPage.jsx b/src/features/about/AboutPage.jsx similarity index 96% rename from src/pages/AboutPage.jsx rename to src/features/about/AboutPage.jsx index f97dc1c..a1328e7 100644 --- a/src/pages/AboutPage.jsx +++ b/src/features/about/AboutPage.jsx @@ -1,8 +1,8 @@ // Author: fruit.com.cn import { ArrowRight, Clapperboard, Cpu, Play, Smartphone, Sparkles, Users } from 'lucide-react'; import { Link } from 'react-router-dom'; -import { useAppStore } from '../app/store'; -import { aboutContent } from '../i18n/about'; +import { useLocale } from '../../app/providers/AppProviders'; +import { aboutContent } from './content'; const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG'; @@ -13,7 +13,7 @@ const PILLARS = [ ]; export function AboutPage() { - const { locale } = useAppStore(); + const { locale } = useLocale(); const copy = aboutContent[locale.code] || aboutContent.en; return ( diff --git a/src/i18n/about.js b/src/features/about/content.js similarity index 100% rename from src/i18n/about.js rename to src/features/about/content.js diff --git a/src/pages/HistoryPage.jsx b/src/features/history/HistoryPage.jsx similarity index 55% rename from src/pages/HistoryPage.jsx rename to src/features/history/HistoryPage.jsx index d57782d..d544687 100644 --- a/src/pages/HistoryPage.jsx +++ b/src/features/history/HistoryPage.jsx @@ -1,10 +1,12 @@ // Author: fruit.com.cn -import { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useAppStore } from '../app/store'; -import { appApi } from '../services/appApi'; -import { normalizePage, normalizeProgress } from '../services/normalizers'; -import { HistoryGridSkeleton } from '../components/Skeletons'; +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 ''; @@ -12,30 +14,22 @@ function episodeLabel(record, localeCode) { } export function HistoryPage() { - const { locale } = useAppStore(); - const [records, setRecords] = useState(null); - const [error, setError] = useState(''); - - const load = useCallback(async () => { - setError(''); - try { - const payload = await appApi.watchRecent(1, 20); - setRecords(normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code))); - } catch (loadError) { - setError(loadError.message || 'Request failed'); - setRecords([]); - } - }, [locale.code]); - - useEffect(() => { load(); }, [load]); + 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 appApi.clearWatchHistory(); - setRecords([]); - } catch (clearError) { - setError(clearError.message || 'Request failed'); + await clearHistory.run(); + history.refetch(); + } catch { + // 错误由 clearHistory.error 呈现 } }; @@ -45,9 +39,9 @@ export function HistoryPage() {

{locale.history}

{!!records?.length && } - {records === null && } - {error &&
{error}
} - {records && !records.length && !error &&
{locale.historyEmpty}
} + {history.loading && records === null && } + {errorMessage &&
{errorMessage}
} + {records && !records.length && !errorMessage &&
{locale.historyEmpty}
} {!!records?.length && (
{records.map((record) => { diff --git a/src/pages/HomePage.jsx b/src/features/home/HomePage.jsx similarity index 51% rename from src/pages/HomePage.jsx rename to src/features/home/HomePage.jsx index 2221e7c..598760c 100644 --- a/src/pages/HomePage.jsx +++ b/src/features/home/HomePage.jsx @@ -1,13 +1,18 @@ // Author: fruit.com.cn import { useMemo, useState } from 'react'; -import { MediaCard } from '../components/MediaCard'; -import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../components/Skeletons'; -import { videos } from '../data/catalog'; -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'; +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; @@ -42,24 +47,26 @@ function fallbackGenreIndex(categoryId) { } export function HomePage() { - const { locale } = useAppStore(); + const { locale } = useLocale(); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID); - const homeApi = useApi( - () => appApi.homeFeed().then((payload) => normalizeHomeItems(payload, locale.code)), + const isAllCategory = activeCategory === ALL_CATEGORY_ID; + + const feedRequest = useRequest( + ({ signal }) => dramaApi.homeFeed({ signal }).then((payload) => normalizeHomeItems(payload, locale.code)), [locale.code], ); - const categoriesApi = useApi( - () => appApi.categories().then((payload) => normalizeCategories(payload, locale.code)), + const categoriesRequest = useRequest( + ({ signal }) => categoryApi.list({ signal }).then((payload) => normalizeCategories(payload, locale.code)), [locale.code], ); - const categoryApi = useApi( - () => (activeCategory === ALL_CATEGORY_ID - ? Promise.resolve(null) - : appApi.categoryDramas(activeCategory).then((payload) => normalizeCategoryItems(payload, 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 = homeApi.data || []; - const serverCategories = categoriesApi.data || []; + + 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], @@ -67,16 +74,17 @@ export function HomePage() { const categories = useMemo(() => { const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] }; if (serverCategories.length) return [allCategory, ...serverCategories]; - return categoriesApi.loading ? [allCategory] : [allCategory, ...fallbackCategories]; - }, [categoriesApi.loading, fallbackCategories, locale.genres, 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 isAllCategory = activeCategory === ALL_CATEGORY_ID; - const loading = isAllCategory ? homeApi.loading : categoryApi.loading; - const error = isAllCategory ? homeApi.error : categoryApi.error; - const remoteItems = isAllCategory ? homeItems : (categoryApi.data || []); + + 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); @@ -84,30 +92,19 @@ export function HomePage() { return ( <> - {categoriesApi.loading && !serverCategories.length ? : ( -
- {categories.map((category) => ( - - ))} -
+ {categoriesRequest.loading && !serverCategories.length ? : ( + )}
{error &&
{locale.apiFallback}
} - {!loading && !error && !items.length &&
{locale.empty}
} - {loading && !items.length ? : ( - - )} + } + empty={!loading && !error && !items.length} + emptyMessage={locale.empty} + > + +
@@ -115,18 +112,18 @@ export function HomePage() {

▶{locale.shortTitle}

- {homeApi.loading && !homeItems.length ? : ( + {feedRequest.loading && !homeItems.length ? : (
{shortsItems.map((video) => )}
)} - {secondShelf.length > 0 &&
-
- {secondShelf.map((video) => )} -
-
} + {secondShelf.length > 0 && ( +
+ +
+ )} ); } diff --git a/src/features/home/components/CategoryBar.jsx b/src/features/home/components/CategoryBar.jsx new file mode 100644 index 0000000..5f7708c --- /dev/null +++ b/src/features/home/components/CategoryBar.jsx @@ -0,0 +1,19 @@ +// Author: fruit.com.cn +export function CategoryBar({ categories, activeId, onChange }) { + return ( +
+ {categories.map((category) => ( + + ))} +
+ ); +} diff --git a/src/features/listing/ListingPage.jsx b/src/features/listing/ListingPage.jsx new file mode 100644 index 0000000..f00e8f4 --- /dev/null +++ b/src/features/listing/ListingPage.jsx @@ -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 ( + <> + + {request.loading ? : ( + <> + {request.error &&
{locale.apiFallback}
} + + + )} + + ); +} diff --git a/src/features/listing/api.js b/src/features/listing/api.js new file mode 100644 index 0000000..0718f3f --- /dev/null +++ b/src/features/listing/api.js @@ -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), +}; diff --git a/src/features/search/SearchPage.jsx b/src/features/search/SearchPage.jsx new file mode 100644 index 0000000..c3438de --- /dev/null +++ b/src/features/search/SearchPage.jsx @@ -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 ( + <> + + {request.loading ? : ( + <> + {request.error &&
{locale.apiFallback}
} + {!request.error && query && results.length === 0 ? ( +
+ + {locale.noResults} “{query}” +
+ ) : ( + + )} + + )} + + ); +} diff --git a/src/features/search/api.js b/src/features/search/api.js new file mode 100644 index 0000000..7a7d02f --- /dev/null +++ b/src/features/search/api.js @@ -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), +}; diff --git a/src/pages/ShortsFeedPage.jsx b/src/features/shorts/ShortsFeedPage.jsx similarity index 55% rename from src/pages/ShortsFeedPage.jsx rename to src/features/shorts/ShortsFeedPage.jsx index e7e0577..147636f 100644 --- a/src/pages/ShortsFeedPage.jsx +++ b/src/features/shorts/ShortsFeedPage.jsx @@ -1,92 +1,22 @@ // Author: fruit.com.cn import { useCallback, useEffect, useRef, useState } from 'react'; -import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; -import { useAppStore } from '../app/store'; -import { appApi } from '../services/appApi'; -import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers'; -import { VideoPlayer } from '../components/player/VideoPlayer'; -import { CommentsSheet } from '../components/CommentsSheet'; -import { ShortsSkeleton } from '../components/Skeletons'; -import { formatCount } from '../lib/format'; -import { reportWatchProgress } from '../services/watchProgress'; -import { useFollowedChannels } from '../hooks/useFollowedChannels'; +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; -function ShortSlide({ video, active, locale, followed, onToggleFollow, onOpenComments, onShare }) { - const src = video.preview?.playUrl; - const [liked, setLiked] = useState(false); - const [likeCount, setLikeCount] = useState(video.likeCount); - const [saved, setSaved] = useState(false); - const [saveCount, setSaveCount] = useState(video.favoriteCount); - - const toggleLike = async () => { - try { - const result = await appApi.toggleLike(video.preview?.id ? 'EPISODE' : 'DRAMA', video.preview?.id || video.id); - setLiked(Boolean(result?.liked)); - setLikeCount(Number(result?.count) || 0); - } catch (error) { - console.error('YMB LIKE FAILED', error); - } - }; - - const toggleSave = async () => { - try { - const next = await appApi.toggleFavorite(video.id); - setSaved(Boolean(next)); - setSaveCount((count) => Math.max(0, count + (next ? 1 : -1))); - } catch (error) { - console.error('YMB SAVE FAILED', error); - } - }; - - return ( -
-
-
-
- {video.channel?.[0] || 'Y'} - {video.channel} - -
-

{video.title}

-
-
-
-
- {src - ? reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> - :
{video.title}{video.title}
} -
-
-
-
- - - - -
-
-
- ); -} - export function ShortsFeedPage() { - const { locale, sidebarOpen } = useAppStore(); + const { locale } = useLocale(); + const { sidebarOpen } = useUi(); const [params] = useSearchParams(); const startId = params.get('start'); const [items, setItems] = useState([]); @@ -94,12 +24,11 @@ export function ShortsFeedPage() { const [status, setStatus] = useState({ loading: true, error: '', hasMore: true }); const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels(); const [commentsVideo, setCommentsVideo] = useState(null); - const [toast, setToast] = useState(''); + const toast = useToast(); const feedRef = useRef(null); const cursorRef = useRef(null); const loadingRef = useRef(false); const requestIdRef = useRef(0); - const toastTimer = useRef(null); const loadPage = useCallback(async (reset) => { if (loadingRef.current && !reset) return; @@ -108,7 +37,7 @@ export function ShortsFeedPage() { loadingRef.current = true; setStatus((prev) => ({ ...prev, loading: true, error: '' })); try { - const payload = await appApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE }); + 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; @@ -158,10 +87,6 @@ export function ShortsFeedPage() { if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false); }, [activeId, items, loadPage, status.hasMore, status.loading]); - useEffect(() => () => { - if (toastTimer.current) window.clearTimeout(toastTimer.current); - }, []); - const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId)); const move = (delta) => { const node = feedRef.current; @@ -170,12 +95,6 @@ export function ShortsFeedPage() { if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' }); }; - const showToast = (message) => { - setToast(message); - if (toastTimer.current) window.clearTimeout(toastTimer.current); - toastTimer.current = window.setTimeout(() => setToast(''), 2000); - }; - const shareVideo = async (video) => { const url = `${window.location.origin}/watch/${video.id}`; if (navigator.share) { @@ -188,9 +107,9 @@ export function ShortsFeedPage() { } try { await navigator.clipboard.writeText(url); - showToast(locale.linkCopied); + toast.show(locale.linkCopied); } catch { - showToast(url); + toast.show(url); } }; @@ -201,6 +120,7 @@ export function ShortsFeedPage() { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex, items.length]); return ( @@ -215,7 +135,6 @@ export function ShortsFeedPage() { key={video.id} video={video} active={video.id === activeId} - locale={locale} followed={followedChannels.includes(video.channel)} onToggleFollow={() => toggleFollow(video.channel)} onOpenComments={() => setCommentsVideo(video)} @@ -231,7 +150,7 @@ export function ShortsFeedPage() { )} {commentsVideo && setCommentsVideo(null)} />} - {toast &&
{toast}
} + {toast.message &&
{toast.message}
} ); } diff --git a/src/features/shorts/components/ShortSlide.jsx b/src/features/shorts/components/ShortSlide.jsx new file mode 100644 index 0000000..3f8dcc1 --- /dev/null +++ b/src/features/shorts/components/ShortSlide.jsx @@ -0,0 +1,62 @@ +// 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 { formatCount } from '../../../shared/lib/format'; +import { reportWatchProgress } from '../../../shared/lib/watchProgress'; + +export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare }) { + const { locale } = useLocale(); + const src = video.preview?.playUrl; + 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 ( +
+
+
+
+ {video.channel?.[0] || 'Y'} + {video.channel} + +
+

{video.title}

+
+
+
+
+ {src + ? reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> + :
{video.title}{video.title}
} +
+
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/features/watch/WatchPage.jsx b/src/features/watch/WatchPage.jsx new file mode 100644 index 0000000..579af58 --- /dev/null +++ b/src/features/watch/WatchPage.jsx @@ -0,0 +1,216 @@ +// Author: fruit.com.cn +import { 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 { 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 { 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 }); + + 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; + setEpisodeId(episode.id); + if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + const goNext = () => changeEpisode(nextEpisode); + const goPrevious = () => changeEpisode(previousEpisode); + + 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
{locale.playbackUnavailable}
; + + 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 ( +
+
+
+
+ {src + ? reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} /> + :
{video.title}{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}
} + {locale.episodeSwipeHint} +
+ + +
+
+
+
+ {label} + {views}{tag ? ` · ${tag}` : ''} +
+

{video.title}

+
+
+ {video.channel?.[0] || 'Y'} +
{video.channel}
+ +
+
+ + + + +
+
+ {video.description && ( +
+

{video.description}

+
+ )} +
+ + changeEpisode(episode, true)} /> + + {commentsOpen && setCommentsOpen(false)} />} + {toast.message &&
{toast.message}
} +
+ ); +} + +export function WatchPage() { + const { id } = useParams(); + const { locale } = useLocale(); + 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]); + + if (!drama.data && drama.loading) return ; + if (!drama.data) return
{drama.error?.message || locale.empty}
; + + return ; +} diff --git a/src/features/watch/components/EpisodeRail.jsx b/src/features/watch/components/EpisodeRail.jsx new file mode 100644 index 0000000..cfc8931 --- /dev/null +++ b/src/features/watch/components/EpisodeRail.jsx @@ -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 ( + + ); +} diff --git a/src/hooks/useApi.js b/src/hooks/useApi.js deleted file mode 100644 index 9460a5f..0000000 --- a/src/hooks/useApi.js +++ /dev/null @@ -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; -} diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index f7b19aa..6018997 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -1,133 +1,33 @@ // Author: fruit.com.cn -import { Bell, Compass, History, Home, Info, ListVideo, Menu, Plus, Search, Upload, UserRound, X } from 'lucide-react'; -import { useEffect, useState, useRef } from 'react'; -import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { useAppStore } from '../app/store'; -import { LanguageSwitcher } from '../components/LanguageSwitcher'; -import { UploadVideoDialog } from '../components/UploadVideoDialog'; +import { Suspense, useState } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; +import { useLocale, useUi } from '../app/providers/AppProviders'; +import { BottomNav } from './components/BottomNav'; +import { Sidebar } from './components/Sidebar'; +import { TopBar } from './components/TopBar'; +import { UploadVideoDialog } from './components/UploadVideoDialog'; export function AppShell() { - const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore(); - const navigate = useNavigate(); + const { locale } = useLocale(); + const { sidebarOpen, setSidebarOpen } = useUi(); const location = useLocation(); - const [searchOpen, setSearchOpen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); - const searchInputRef = useRef(null); - const createRef = useRef(null); - const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [ListVideo, locale.subscriptions, '/subscriptions']]; - const libraryItems = [[History, locale.history, '/history']]; - const aboutItems = [[Info, locale.about, '/about']]; const immersive = location.pathname === '/shorts'; - // 移动端为抽屉式侧边栏,点击任一菜单项后必须收起 - const closeSidebarOnNavigate = (event) => { - if (!event.target.closest?.('a')) return; - if (window.matchMedia('(max-width: 760px)').matches) setSidebarOpen(false); - }; - useEffect(() => { - if (!createOpen) return undefined; - const closeMenu = (event) => { - if (!createRef.current?.contains(event.target)) setCreateOpen(false); - }; - document.addEventListener('mousedown', closeMenu); - return () => document.removeEventListener('mousedown', closeMenu); - }, [createOpen]); - const submitSearch = (event) => { - event.preventDefault(); - setSearchOpen(false); - const keyword = query.trim(); - navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search'); - }; - const navClass = (to) => { - const [path, search = ''] = to.split('?'); - if (location.pathname !== path) return ''; - const current = new URLSearchParams(location.search); - return [...new URLSearchParams(search)].every(([key, value]) => current.get(key) === value) ? 'active' : ''; - }; return ( <> -
-
- - - YMB - -
-
-
- - setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} /> - {query && ( - - )} - - -
-
-
- - -
- - {createOpen && ( -
- -
- )} -
- - N -
-
- - - -
{!immersive &&
© {new Date().getFullYear()} {locale.copyright}
}
- - {sidebarOpen && } + setSidebarOpen(!sidebarOpen)} onUpload={() => setUploadOpen(true)} /> + +
+ }> + + + {!immersive &&
© {new Date().getFullYear()} {locale.copyright}
} +
+ + {sidebarOpen && ( + + {open && ( +
+ +
+ )} + + ); +} diff --git a/src/components/LanguageFlag.jsx b/src/layouts/components/LanguageFlag.jsx similarity index 100% rename from src/components/LanguageFlag.jsx rename to src/layouts/components/LanguageFlag.jsx diff --git a/src/components/LanguageSwitcher.jsx b/src/layouts/components/LanguageSwitcher.jsx similarity index 94% rename from src/components/LanguageSwitcher.jsx rename to src/layouts/components/LanguageSwitcher.jsx index 5c9ed9f..d82422f 100644 --- a/src/components/LanguageSwitcher.jsx +++ b/src/layouts/components/LanguageSwitcher.jsx @@ -1,7 +1,7 @@ // Author: fruit.com.cn import { Check, ChevronDown } from 'lucide-react'; import { useState } from 'react'; -import { useAppStore } from '../app/store'; +import { useLocale } from '../../app/providers/AppProviders'; import { LanguageFlag } from './LanguageFlag'; const languages = [ @@ -12,7 +12,7 @@ const languages = [ const titles = { id: 'Bahasa', en: 'Language', zh: '语言' }; export function LanguageSwitcher() { - const { lang, setLang } = useAppStore(); + const { lang, setLang } = useLocale(); const [open, setOpen] = useState(false); const current = languages.find((item) => item.code === lang) || languages[0]; const title = titles[lang] || 'Language'; diff --git a/src/layouts/components/SearchBox.jsx b/src/layouts/components/SearchBox.jsx new file mode 100644 index 0000000..e5bd291 --- /dev/null +++ b/src/layouts/components/SearchBox.jsx @@ -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 ( +
+ + setQuery(event.target.value)} + placeholder={locale.search} + aria-label={locale.search} + /> + {query && ( + + )} + + +
+ ); +} diff --git a/src/layouts/components/Sidebar.jsx b/src/layouts/components/Sidebar.jsx new file mode 100644 index 0000000..aea1c3c --- /dev/null +++ b/src/layouts/components/Sidebar.jsx @@ -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 ( + + ); +} diff --git a/src/layouts/components/TopBar.jsx b/src/layouts/components/TopBar.jsx new file mode 100644 index 0000000..5fa6a0e --- /dev/null +++ b/src/layouts/components/TopBar.jsx @@ -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 ( +
+
+ + + YMB + +
+
+ setSearchOpen(false)} /> +
+
+ + + + + N +
+
+ ); +} diff --git a/src/components/UploadVideoDialog.jsx b/src/layouts/components/UploadVideoDialog.jsx similarity index 97% rename from src/components/UploadVideoDialog.jsx rename to src/layouts/components/UploadVideoDialog.jsx index 9498dc3..a2e9097 100644 --- a/src/components/UploadVideoDialog.jsx +++ b/src/layouts/components/UploadVideoDialog.jsx @@ -1,7 +1,7 @@ // Author: fruit.com.cn import { CheckCircle2, UploadCloud, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import { useAppStore } from '../app/store'; +import { useLocale } from '../../app/providers/AppProviders'; function formatSize(bytes) { if (!bytes) return ''; @@ -10,7 +10,7 @@ function formatSize(bytes) { } export function UploadVideoDialog({ open, onClose }) { - const { locale } = useAppStore(); + const { locale } = useLocale(); const [file, setFile] = useState(null); const [title, setTitle] = useState(''); const [dragging, setDragging] = useState(false); diff --git a/src/main.jsx b/src/main.jsx index f843dba..91cba73 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,8 +1,8 @@ // 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'; createRoot(document.getElementById('root')).render(); diff --git a/src/pages/ListingPage.jsx b/src/pages/ListingPage.jsx deleted file mode 100644 index abff062..0000000 --- a/src/pages/ListingPage.jsx +++ /dev/null @@ -1,27 +0,0 @@ -// Author: fruit.com.cn -import { MediaCard } from '../components/MediaCard'; -import { SectionHeading } from '../components/SectionHeading'; -import { VideoGridSkeleton } from '../components/Skeletons'; -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 ( - <> - - {api.loading ? : ( - <> - {api.error &&
{locale.apiFallback}
} -
{items.map((video) => )}
- - )} - - ); -} diff --git a/src/pages/SearchPage.jsx b/src/pages/SearchPage.jsx deleted file mode 100644 index 8a70524..0000000 --- a/src/pages/SearchPage.jsx +++ /dev/null @@ -1,46 +0,0 @@ -// Author: fruit.com.cn -import { Search as SearchIcon } from 'lucide-react'; -import { useSearchParams } from 'react-router-dom'; -import { MediaCard } from '../components/MediaCard'; -import { SectionHeading } from '../components/SectionHeading'; -import { VideoGridSkeleton } from '../components/Skeletons'; -import { useAppStore } from '../app/store'; -import { appApi } from '../services/appApi'; -import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers'; -import { useApi } from '../hooks/useApi'; - -async function loadSearchResults(keyword, localeCode) { - if (keyword) { - const payload = await appApi.search(keyword); - return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)); - } - const payload = await appApi.homeFeed(); - const items = payload?.sections?.flatMap((section) => section.dramas || []) || []; - return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode))); -} - -export function SearchPage() { - const { locale } = useAppStore(); - const [params] = useSearchParams(); - const query = (params.get('q') || '').trim(); - const api = useApi(() => loadSearchResults(query, locale.code), [query, locale.code]); - const results = api.data || []; - return ( - <> - - {api.loading ? : ( - <> - {api.error &&
{locale.apiFallback}
} - {!api.error && query && results.length === 0 ? ( -
- - {locale.noResults} “{query}” -
- ) : ( -
{results.map((video) => )}
- )} - - )} - - ); -} diff --git a/src/pages/WatchPage.jsx b/src/pages/WatchPage.jsx deleted file mode 100644 index 4d242d2..0000000 --- a/src/pages/WatchPage.jsx +++ /dev/null @@ -1,235 +0,0 @@ -// Author: fruit.com.cn -import { useEffect, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { Bookmark, Lock, MessageCircle, Share2, ThumbsUp } from 'lucide-react'; -import { useAppStore } from '../app/store'; -import { appApi } from '../services/appApi'; -import { normalizeDrama, selectPlaybackUrl } from '../services/normalizers'; -import { useApi } from '../hooks/useApi'; -import { useFollowedChannels } from '../hooks/useFollowedChannels'; -import { VideoPlayer } from '../components/player/VideoPlayer'; -import { CommentsSheet } from '../components/CommentsSheet'; -import { WatchPageSkeleton } from '../components/Skeletons'; -import { formatCount, formatViews } from '../lib/format'; -import { reportWatchProgress } from '../services/watchProgress'; - -function episodeLabel(episode, index, code) { - const number = episode?.number ?? index + 1; - return code === 'zh' ? `第${number}集` : `Episode ${number}`; -} - -function WatchContent({ video, locale }) { - const episodes = video.episodes?.length - ? (video.preview && !video.episodes.some((item) => item.id === video.preview.id) ? [video.preview, ...video.episodes] : video.episodes) - : (video.preview ? [video.preview] : []); - 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 playback = useApi(() => currentEpisode && !currentEpisode.locked ? appApi.episodePlay(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id, currentEpisode?.locked]); - const stats = useApi(() => currentEpisode?.id ? appApi.episodeStats(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id]); - 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 { channels, toggle } = useFollowedChannels(); - const followed = channels.includes(video.channel); - const [liked, setLiked] = useState(false); - const [likeCount, setLikeCount] = useState(Number(currentEpisode?.likeCount) || 0); - const [saved, setSaved] = useState(false); - const [saveCount, setSaveCount] = useState(Number(video.favoriteCount) || 0); - const [commentsOpen, setCommentsOpen] = useState(false); - const [toast, setToast] = useState(''); - const toastTimer = useRef(null); - const swipeStart = useRef(null); - const playerRef = useRef(null); - - useEffect(() => { - const data = stats.data; - if (!data) return; - setLiked(Boolean(data.liked)); - setLikeCount(Number(data.likeCount) || 0); - setSaved(Boolean(data.favorited)); - setSaveCount(Number(data.favoriteCount) || 0); - }, [stats.data]); - - useEffect(() => () => { - if (toastTimer.current) window.clearTimeout(toastTimer.current); - }, []); - - const changeEpisode = (episode, scroll = false) => { - if (!episode || episode.id === currentEpisode?.id) return; - setEpisodeId(episode.id); - if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }; - const goNext = () => changeEpisode(nextEpisode); - const goPrevious = () => changeEpisode(previousEpisode); - - useEffect(() => { - const node = playerRef.current; - if (!node) return undefined; - let last = 0; - const onWheel = (event) => { - if (Math.abs(event.deltaY) < 20) return; - const forward = event.deltaY > 0; - if (forward ? !nextEpisode : !previousEpisode) return; - event.preventDefault(); - const now = Date.now(); - if (now - last < 700) return; - last = now; - if (forward) goNext(); else goPrevious(); - }; - node.addEventListener('wheel', onWheel, { passive: false }); - return () => node.removeEventListener('wheel', onWheel); - }, [nextEpisode, previousEpisode]); - - if (!currentEpisode) return
{locale.playbackUnavailable}
; - const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0; - - 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 showToast = (message) => { - setToast(message); - if (toastTimer.current) window.clearTimeout(toastTimer.current); - toastTimer.current = window.setTimeout(() => setToast(''), 2000); - }; - - const toggleLike = async () => { - try { - const result = await appApi.toggleLike('EPISODE', currentEpisode.id); - setLiked(Boolean(result?.liked)); - setLikeCount(Number(result?.count) || 0); - } catch (error) { - console.error('YMB LIKE FAILED', error); - } - }; - - const toggleSave = async () => { - try { - const next = await appApi.toggleFavorite(video.id); - setSaved(Boolean(next)); - setSaveCount((count) => Math.max(0, count + (next ? 1 : -1))); - } catch (error) { - console.error('YMB SAVE FAILED', error); - } - }; - - 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); - showToast(locale.linkCopied); - } catch { - showToast(url); - } - }; - - const label = episodeLabel(currentEpisode, episodeIndex, locale.code); - const commentVideo = { ...video, preview: currentEpisode }; - - return ( -
-
-
- {src - ? reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} /> - :
{video.title}{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error || locale.playbackUnavailable}
} - {locale.episodeSwipeHint} -
- - -
-
-

{video.title}

-

{label}

-
-
- {video.channel?.[0] || 'Y'} -
{video.channel}
- -
-
- - - - -
-
-
- {views}{tag ? ` · ${tag}` : ''} - {video.description &&

{video.description}

} -
-
- - {episodes.length > 0 && ( - - )} - - {commentsOpen && setCommentsOpen(false)} />} - {toast &&
{toast}
} -
- ); -} - -export function WatchPage() { - const { id } = useParams(); - const { locale } = useAppStore(); - const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); - const video = current.data; - - useEffect(() => { - window.scrollTo({ top: 0 }); - const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0); - return () => window.clearTimeout(timer); - }, [id]); - - if (!video && current.loading) return ; - if (!video) return
{current.error || locale.empty}
; - - return ; -} diff --git a/src/services/appApi.js b/src/services/appApi.js deleted file mode 100644 index 9e04b8b..0000000 --- a/src/services/appApi.js +++ /dev/null @@ -1,31 +0,0 @@ -// Author: fruit.com.cn -import { request } from './httpClient'; - -export const appApi = { - homeFeed: () => request('/app/home/feed'), - feed: ({ types = '', categoryId = '', cursor, size = 10 } = {}) => { - const params = new URLSearchParams({ size: String(size) }); - if (types) params.set('types', types); - if (categoryId) params.set('categoryId', String(categoryId)); - if (cursor !== undefined && cursor !== null && cursor !== '') params.set('cursor', String(cursor)); - return request(`/app/feed?${params.toString()}`); - }, - categories: () => request('/app/categories'), - categoryDramas: (id, pageIndex = 1, pageSize = 20) => request(`/app/categories/${encodeURIComponent(id)}/dramas?pageIndex=${pageIndex}&pageSize=${pageSize}`), - 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`), - episodeStats: (id) => request(`/app/episodes/${encodeURIComponent(id)}/stats`), - search: (keyword) => request(`/app/search?keyword=${encodeURIComponent(keyword)}&pageIndex=1&pageSize=48`), - progress: (body) => request('/app/watch/progress', { method: 'POST', body: JSON.stringify(body) }), - watchRecent: (pageIndex = 1, pageSize = 20) => request(`/app/watch/recent?pageIndex=${pageIndex}&pageSize=${pageSize}`), - clearWatchHistory: () => request('/app/watch/progress', { method: 'DELETE' }), - toggleLike: (targetType, targetId) => request('/app/likes/toggle', { method: 'POST', body: JSON.stringify({ targetType, targetId }) }), - toggleFavorite: (dramaId) => request('/app/favorites/toggle', { method: 'POST', body: JSON.stringify({ dramaId }) }), - comments: ({ dramaId, episodeId, size = 30 }) => { - const params = new URLSearchParams({ dramaId: String(dramaId), size: String(size) }); - if (episodeId) params.set('episodeId', String(episodeId)); - return request(`/app/comments?${params.toString()}`); - }, - publishComment: ({ dramaId, episodeId, content }) => request('/app/comments', { method: 'POST', body: JSON.stringify({ dramaId, ...(episodeId ? { episodeId } : {}), content }) }), -}; diff --git a/src/services/httpClient.js b/src/services/httpClient.js deleted file mode 100644 index ab6a8c6..0000000 --- a/src/services/httpClient.js +++ /dev/null @@ -1,24 +0,0 @@ -// Author: fruit.com.cn -import { getDeviceId } from './device'; - -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', 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...(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); - } -} diff --git a/src/services/normalizers.js b/src/services/normalizers.js deleted file mode 100644 index 5145bde..0000000 --- a/src/services/normalizers.js +++ /dev/null @@ -1,43 +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 || '', 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: 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); } - -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 || '', - }; -} diff --git a/src/shared/api/client.js b/src/shared/api/client.js new file mode 100644 index 0000000..5a0dc44 --- /dev/null +++ b/src/shared/api/client.js @@ -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; +} diff --git a/src/shared/api/config.js b/src/shared/api/config.js new file mode 100644 index 0000000..d06ab34 --- /dev/null +++ b/src/shared/api/config.js @@ -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', + }, +}; diff --git a/src/shared/api/engagement.js b/src/shared/api/engagement.js new file mode 100644 index 0000000..cc5388d --- /dev/null +++ b/src/shared/api/engagement.js @@ -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), +}; diff --git a/src/shared/api/errors.js b/src/shared/api/errors.js new file mode 100644 index 0000000..bfd6668 --- /dev/null +++ b/src/shared/api/errors.js @@ -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'; +} diff --git a/src/shared/api/index.js b/src/shared/api/index.js new file mode 100644 index 0000000..a8e1dc4 --- /dev/null +++ b/src/shared/api/index.js @@ -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()); diff --git a/src/shared/api/interceptors.js b/src/shared/api/interceptors.js new file mode 100644 index 0000000..c1aa5bb --- /dev/null +++ b/src/shared/api/interceptors.js @@ -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; +} diff --git a/src/shared/api/query.js b/src/shared/api/query.js new file mode 100644 index 0000000..65c9aeb --- /dev/null +++ b/src/shared/api/query.js @@ -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}` : ''; +} diff --git a/src/shared/api/watch.js b/src/shared/api/watch.js new file mode 100644 index 0000000..9b745c9 --- /dev/null +++ b/src/shared/api/watch.js @@ -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), +}; diff --git a/src/shared/components/AsyncBoundary.jsx b/src/shared/components/AsyncBoundary.jsx new file mode 100644 index 0000000..3441586 --- /dev/null +++ b/src/shared/components/AsyncBoundary.jsx @@ -0,0 +1,16 @@ +// Author: fruit.com.cn +export function EmptyState({ message }) { + return
{message}
; +} + +export function ErrorState({ message }) { + return
{message}
; +} + +/** 统一 loading / error / empty 三态渲染。 */ +export function AsyncBoundary({ loading, error, empty, skeleton = null, errorMessage, emptyMessage, children }) { + if (loading) return skeleton; + if (error) return ; + if (empty) return ; + return children; +} diff --git a/src/components/CommentsSheet.jsx b/src/shared/components/CommentsSheet.jsx similarity index 89% rename from src/components/CommentsSheet.jsx rename to src/shared/components/CommentsSheet.jsx index d27771b..631a78b 100644 --- a/src/components/CommentsSheet.jsx +++ b/src/shared/components/CommentsSheet.jsx @@ -1,7 +1,7 @@ // Author: fruit.com.cn import { useEffect, useState } from 'react'; import { Send, X } from 'lucide-react'; -import { appApi } from '../services/appApi'; +import { engagementApi } from '../api/engagement'; function formatTime(value, htmlLang) { if (!value) return ''; @@ -16,7 +16,7 @@ export function CommentsSheet({ video, locale, onClose, onPosted }) { const [text, setText] = useState(''); const [sending, setSending] = useState(false); - const loadComments = () => appApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) + const loadComments = () => engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) .then((payload) => setComments(payload?.list || [])) .catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); }); @@ -24,7 +24,7 @@ export function CommentsSheet({ video, locale, onClose, onPosted }) { let active = true; setComments(null); setError(''); - appApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) + 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; }; @@ -37,7 +37,7 @@ export function CommentsSheet({ video, locale, onClose, onPosted }) { setSending(true); setError(''); try { - await appApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content }); + await engagementApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content }); setText(''); await loadComments(); onPosted?.(); diff --git a/src/components/MediaCard.jsx b/src/shared/components/MediaCard.jsx similarity index 92% rename from src/components/MediaCard.jsx rename to src/shared/components/MediaCard.jsx index 2ce0147..cc86b60 100644 --- a/src/components/MediaCard.jsx +++ b/src/shared/components/MediaCard.jsx @@ -1,11 +1,11 @@ // Author: fruit.com.cn import { MoreVertical, Play } from 'lucide-react'; import { Link } from 'react-router-dom'; -import { useAppStore } from '../app/store'; +import { useLocale } from '../../app/providers/AppProviders'; import { daysAgoLabel, formatViews } from '../lib/format'; export function MediaCard({ video, compact = false, to }) { - const { locale } = useAppStore(); + 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(' · '); diff --git a/src/components/SectionHeading.jsx b/src/shared/components/SectionHeading.jsx similarity index 100% rename from src/components/SectionHeading.jsx rename to src/shared/components/SectionHeading.jsx diff --git a/src/shared/components/VideoGrid.jsx b/src/shared/components/VideoGrid.jsx new file mode 100644 index 0000000..51d7b4c --- /dev/null +++ b/src/shared/components/VideoGrid.jsx @@ -0,0 +1,10 @@ +// Author: fruit.com.cn +import { MediaCard } from './MediaCard'; + +export function VideoGrid({ items = [] }) { + return ( +
+ {items.map((video) => )} +
+ ); +} diff --git a/src/shared/components/player/VideoPlayer.jsx b/src/shared/components/player/VideoPlayer.jsx new file mode 100644 index 0000000..ad47e68 --- /dev/null +++ b/src/shared/components/player/VideoPlayer.jsx @@ -0,0 +1,392 @@ +// 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, onProgress }) { + 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 [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); + 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); + 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 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 ( +
playing && setControlsVisible(false)} + onKeyDown={onKeyDown} + tabIndex={0} + role="group" + aria-label="Video player" + > +
+ ); +} diff --git a/src/components/Skeletons.jsx b/src/shared/components/skeletons/index.jsx similarity index 100% rename from src/components/Skeletons.jsx rename to src/shared/components/skeletons/index.jsx diff --git a/src/data/catalog.js b/src/shared/data/catalog.js similarity index 100% rename from src/data/catalog.js rename to src/shared/data/catalog.js diff --git a/src/services/catalogService.js b/src/shared/data/catalogService.js similarity index 100% rename from src/services/catalogService.js rename to src/shared/data/catalogService.js diff --git a/src/shared/hooks/useEngagement.js b/src/shared/hooks/useEngagement.js new file mode 100644 index 0000000..ef1e4e6 --- /dev/null +++ b/src/shared/hooks/useEngagement.js @@ -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, + }; +} diff --git a/src/hooks/useFollowedChannels.js b/src/shared/hooks/useFollowedChannels.js similarity index 100% rename from src/hooks/useFollowedChannels.js rename to src/shared/hooks/useFollowedChannels.js diff --git a/src/shared/hooks/useMutation.js b/src/shared/hooks/useMutation.js new file mode 100644 index 0000000..cc8a5f7 --- /dev/null +++ b/src/shared/hooks/useMutation.js @@ -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 }; +} diff --git a/src/shared/hooks/useRequest.js b/src/shared/hooks/useRequest.js new file mode 100644 index 0000000..5f3835b --- /dev/null +++ b/src/shared/hooks/useRequest.js @@ -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 }; +} diff --git a/src/shared/hooks/useToast.js b/src/shared/hooks/useToast.js new file mode 100644 index 0000000..75ae402 --- /dev/null +++ b/src/shared/hooks/useToast.js @@ -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 }; +} diff --git a/src/services/device.js b/src/shared/lib/device.js similarity index 100% rename from src/services/device.js rename to src/shared/lib/device.js diff --git a/src/lib/format.js b/src/shared/lib/format.js similarity index 100% rename from src/lib/format.js rename to src/shared/lib/format.js diff --git a/src/shared/lib/geo.js b/src/shared/lib/geo.js new file mode 100644 index 0000000..c7a5721 --- /dev/null +++ b/src/shared/lib/geo.js @@ -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); + } +} diff --git a/src/shared/lib/media.js b/src/shared/lib/media.js new file mode 100644 index 0000000..6594449 --- /dev/null +++ b/src/shared/lib/media.js @@ -0,0 +1,18 @@ +// 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 const resolvePlaybackUrl = resolveMedia; + +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 resolveMedia(preferred?.playUrl || playback?.playUrl || fallback); +} diff --git a/src/shared/lib/pagination.js b/src/shared/lib/pagination.js new file mode 100644 index 0000000..41bd3bc --- /dev/null +++ b/src/shared/lib/pagination.js @@ -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 || [] }; +} diff --git a/src/lib/seo.js b/src/shared/lib/seo.js similarity index 100% rename from src/lib/seo.js rename to src/shared/lib/seo.js diff --git a/src/shared/lib/text.js b/src/shared/lib/text.js new file mode 100644 index 0000000..c4f8ebb --- /dev/null +++ b/src/shared/lib/text.js @@ -0,0 +1,5 @@ +// Author: fruit.com.cn +export function getText(value, lang = 'id') { + if (typeof value === 'string') return value; + return value?.[lang] || value?.en || value?.id || value?.zh || ''; +} diff --git a/src/services/watchProgress.js b/src/shared/lib/watchProgress.js similarity index 93% rename from src/services/watchProgress.js rename to src/shared/lib/watchProgress.js index 3d5e2aa..351cf3a 100644 --- a/src/services/watchProgress.js +++ b/src/shared/lib/watchProgress.js @@ -1,5 +1,5 @@ // Author: fruit.com.cn -import { appApi } from './appApi'; +import { watchApi } from '../api/watch'; const REPORT_INTERVAL_MS = 10000; const lastReportedAt = new Map(); @@ -13,7 +13,7 @@ export function reportWatchProgress(episodeId, positionSeconds, durationSeconds, lastReportedAt.set(id, now); const positionMs = Math.max(0, Math.round((Number(positionSeconds) || 0) * 1000)); const durationMs = Math.max(0, Math.round((Number(durationSeconds) || 0) * 1000)); - appApi.progress({ + watchApi.report({ episodeId: id, positionMs, ...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}), diff --git a/src/styles.css b/src/shared/styles/base.css similarity index 100% rename from src/styles.css rename to src/shared/styles/base.css diff --git a/src/enterprise.css b/src/shared/styles/enterprise.css similarity index 100% rename from src/enterprise.css rename to src/shared/styles/enterprise.css diff --git a/src/youtube-overrides.css b/src/shared/styles/youtube-overrides.css similarity index 58% rename from src/youtube-overrides.css rename to src/shared/styles/youtube-overrides.css index a65febc..5c996e2 100644 --- a/src/youtube-overrides.css +++ b/src/shared/styles/youtube-overrides.css @@ -66,7 +66,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #272727; color: #fff; } .language-trigger svg { color: #b9b9b9; } .language-trigger .language-code { color: #fff; font-size: 12px; font-weight: 600; } -.language-trigger .language-flag { display: inline-flex; flex: none; width: 20px; height: 14px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; } +.language-trigger .language-flag { display: inline-flex; flex: none; width: 20px; height: 20px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; } .language-flag svg { display: block; width: 100%; height: 100%; } .language-trigger .language-chevron { color: #8a8a8a; } .profile-chip { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #065fd4; color: #fff; } @@ -128,7 +128,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .language-menu button.selected { background: #ffffff0f; color: #fff; } .language-option-label { flex: 1; font-size: 14px; } .language-option-check { flex: none; color: #ff8a82; } -.language-menu .language-flag { display: inline-flex; flex: none; width: 22px; height: 15px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; } +.language-menu .language-flag { display: inline-flex; flex: none; width: 22px; height: 22px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; } /* Shorts:客户端式全屏竖滑视频流 */ .shorts-stage { position: fixed; top: var(--topbar); left: var(--sidebar); right: 0; bottom: 0; z-index: 6; margin: 0; overflow: hidden; background: #000; } @@ -182,55 +182,60 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .comment-form button { display: grid; place-items: center; width: 44px; height: 40px; border: 0; border-radius: 20px; background: #ff0033; color: #fff; cursor: pointer; } .comment-form button:disabled { opacity: .35; cursor: not-allowed; } /* 播放页 */ -.watch-page { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 32px; max-width: 1280px; margin: 0 auto; padding: 8px 0 32px; align-items: start; } +.watch-page { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 36px; max-width: 1320px; margin: 0 auto; padding: 8px 0 48px; align-items: start; } .watch-primary { min-width: 0; } -.watch-player { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #000; touch-action: none; } +.watch-stage { position: relative; display: grid; place-items: center; padding: 22px 20px; overflow: hidden; border-radius: 24px; background: radial-gradient(120% 90% at 50% 0%, #1d1e25, #09090b 72%); box-shadow: 0 30px 80px #0006, inset 0 1px 0 #ffffff0d; } +.watch-stage::before { content: ''; position: absolute; inset: -35%; background-image: var(--watch-poster); background-position: center; background-size: cover; filter: blur(56px) saturate(1.4); opacity: .4; transform: scale(1.15); pointer-events: none; } +.watch-stage::after { content: ''; position: absolute; inset: 0; background: radial-gradient(72% 72% at 50% 46%, #00000000 20%, #08080ae0 92%); pointer-events: none; } +.watch-player { position: relative; z-index: 1; width: auto; max-width: 100%; height: min(64vh, 620px); aspect-ratio: 9 / 16; overflow: hidden; border-radius: 18px; background: #000; box-shadow: 0 40px 100px #000e; touch-action: none; } .watch-player .video-player { width: 100%; height: 100%; object-fit: contain; } .watch-player-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; } -.watch-player-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .35; } -.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; } +.watch-player-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .4; } +.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 999px; background: #000b; color: #fff; font-size: 13px; backdrop-filter: blur(6px); } .watch-swipe-tip { position: absolute; top: 12px; right: 12px; z-index: 2; display: none; padding: 5px 10px; border-radius: 999px; background: #0009; color: #ddd; font-size: 11px; pointer-events: none; } @media (max-width: 760px) { .watch-swipe-tip { display: block; } } .watch-arrows { position: absolute; top: 50%; right: 12px; z-index: 3; display: grid; gap: 8px; transform: translateY(-50%); } -.watch-arrows button { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #ffffff24; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; backdrop-filter: blur(6px); } +.watch-arrows button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; box-shadow: 0 8px 22px #0008; backdrop-filter: blur(6px); } .watch-arrows button:disabled { opacity: .35; cursor: not-allowed; } @media (max-width: 760px) { .watch-arrows { display: none; } } -.watch-title { margin: 16px 0 12px; color: #fff; font-size: 20px; font-weight: 600; line-height: 1.4; } -.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; } +.watch-meta { display: flex; align-items: center; gap: 10px; margin: 22px 2px 10px; color: #9a9a9a; font-size: 12.5px; } +.watch-badge { padding: 4px 10px; border-radius: 999px; background: #ffffff10; color: #eaeaea; font-size: 12px; font-weight: 600; letter-spacing: .02em; } +.watch-title { margin: 0 0 18px; color: #fff; font-size: 26px; font-weight: 700; line-height: 1.35; letter-spacing: -.015em; } +.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0 16px; border-top: 1px solid #ffffff0d; border-bottom: 1px solid #ffffff0d; } .watch-channel { display: flex; align-items: center; gap: 12px; min-width: 0; } -.watch-avatar { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 15px; font-weight: 700; } +.watch-avatar { display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 50%; background: linear-gradient(135deg, #2b6cff, #7a3cff); color: #fff; font-size: 15px; font-weight: 700; box-shadow: 0 0 0 2px #0f0f0f, 0 0 0 3px #ffffff1f; } .watch-channel-copy { min-width: 0; } -.watch-channel-copy b { color: #fff; font-size: 14px; font-weight: 600; } -.watch-subscribe { margin-left: 2px; border: 0; border-radius: 20px; padding: 8px 16px; background: #fff; color: #111; font-size: 13px; font-weight: 600; cursor: pointer; } -.watch-subscribe.followed { background: #272727; color: #fff; } +.watch-channel-copy b { color: #f5f5f5; font-size: 14px; font-weight: 600; } +.watch-subscribe { margin-left: 2px; border: 0; border-radius: 999px; padding: 9px 20px; background: #fff; color: #0b0b0b; font-size: 13px; font-weight: 700; cursor: pointer; transition: transform .18s ease, background .18s ease; } +.watch-subscribe:hover { transform: translateY(-1px); } +.watch-subscribe.followed { background: #ffffff14; color: #e8e8e8; box-shadow: inset 0 0 0 1px #ffffff24; } .watch-actions { display: flex; align-items: center; gap: 8px; } -.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; } -.watch-actions button:hover { background: #343434; } -.watch-actions button.active { background: #ff0033; } -.watch-description { margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #1a1a1a; color: #ccc; font-size: 14px; line-height: 1.6; } -.watch-description b { display: block; margin-bottom: 4px; color: #fff; font-size: 13px; font-weight: 600; } +.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; border: 0; border-radius: 999px; background: #ffffff0f; color: #f0f0f0; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .18s ease, transform .16s ease; } +.watch-actions button:hover { background: #ffffff1e; transform: translateY(-1px); } +.watch-actions button.active { background: #ff2d55; color: #fff; } +.watch-description { margin-top: 18px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(180deg, #17171a, #111113); color: #c7c7c7; font-size: 14px; line-height: 1.75; box-shadow: 0 16px 40px #0005; } .watch-description p { margin: 0; white-space: pre-wrap; } -.watch-episode { margin: -6px 0 12px; color: #aaa; font-size: 13px; } -.watch-side { min-width: 0; } -.watch-side h2 { margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600; } -.watch-episode-list { display: grid; gap: 8px; } -.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 10px; background: transparent; text-align: left; cursor: pointer; } -.watch-episode-item:hover { background: #1f1f1f; } -.watch-episode-item.active { background: #2a2a2a; } -.watch-episode-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; } +.watch-side { position: sticky; top: calc(var(--topbar) + 16px); min-width: 0; padding: 18px; border-radius: 20px; background: linear-gradient(180deg, #151518, #101012); box-shadow: 0 24px 60px #0006, inset 0 1px 0 #ffffff0a; } +.watch-side h2 { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 14px; color: #fff; font-size: 15px; font-weight: 600; letter-spacing: .01em; } +.watch-episode-count { padding: 3px 9px; border-radius: 999px; background: #ffffff0d; color: #9a9a9a; font-size: 11px; font-weight: 600; } +.watch-episode-list { display: grid; gap: 6px; max-height: calc(100vh - var(--topbar) - 150px); overflow-y: auto; padding-right: 4px; scrollbar-width: thin; scrollbar-color: #3a3a3a transparent; } +.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 12px; background: transparent; text-align: left; cursor: pointer; transition: background .18s ease; } +.watch-episode-item:hover { background: #ffffff0a; } +.watch-episode-item.active { background: #ffffff12; box-shadow: 0 10px 28px #0007; } +.watch-episode-thumb { position: relative; flex: none; width: 96px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 8px; background: #222; } .watch-episode-thumb img { width: 100%; height: 100%; object-fit: cover; } .watch-episode-thumb b { position: absolute; right: 4px; bottom: 4px; padding: 1px 4px; border-radius: 3px; background: #000c; color: #fff; font-size: 11px; font-weight: 600; } -.watch-episode-lock { position: absolute; inset: 0; display: grid; place-items: center; background: #0009; color: #fff; } +.watch-episode-lock { position: absolute; inset: 0; display: grid; place-items: center; background: #000a; color: #fff; backdrop-filter: blur(2px); } .watch-episode-copy { display: grid; gap: 3px; min-width: 0; align-content: center; } -.watch-episode-copy b { color: #fff; font-size: 13px; font-weight: 500; } -.watch-episode-copy small { color: #aaa; font-size: 12px; } +.watch-episode-copy b { color: #f2f2f2; font-size: 13px; font-weight: 600; } +.watch-episode-copy small { color: #8f8f8f; font-size: 11.5px; } .watch-side-card { display: flex; align-items: flex-start; gap: 10px; } .watch-side-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; } -.watch-skeleton-player { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; } -.watch-skeleton-title { width: 72%; height: 26px; margin-top: 18px; } +.watch-skeleton-player { width: min(100%, 372px); aspect-ratio: 9 / 16; margin: 0 auto; border-radius: 18px; } +.watch-skeleton-title { width: 72%; height: 26px; margin-top: 22px; } .watch-skeleton-row { display: flex; align-items: center; gap: 12px; margin-top: 18px; } .watch-skeleton-channel { width: 160px; height: 36px; border-radius: 18px; } -.watch-skeleton-description { height: 96px; margin-top: 18px; border-radius: 12px; } +.watch-skeleton-description { height: 96px; margin-top: 18px; border-radius: 16px; } .watch-skeleton-heading { width: 90px; height: 18px; margin-bottom: 16px; } @media (max-width: 1180px) { .watch-page { grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; } } @media (max-width: 980px) { .watch-page { display: block; } .watch-side { margin-top: 28px; } } @@ -272,58 +277,72 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .shorts-skeleton-copy { position: absolute; right: 4px; bottom: 30px; left: 4px; display: grid; gap: 8px; } /* 关于 YMB */ -.about-page { max-width: 1000px; margin: 0 auto; padding: 8px 0 24px; } -.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(250px, .75fr); gap: 32px; align-items: center; overflow: hidden; padding: 56px 48px; border: 1px solid #2a2a2a; border-radius: 24px; background: linear-gradient(135deg, #0c1a30, #141414 55%, #2a0d16); } -.about-hero::before { content: ''; position: absolute; top: -140px; right: -60px; width: 420px; height: 420px; border-radius: 50%; background: radial-gradient(circle, #1f6feb55, transparent 65%); } -.about-hero::after { content: ''; position: absolute; bottom: -180px; left: 22%; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff003344, transparent 65%); } -.about-hero-copy, .about-hero-visual { position: relative; z-index: 1; } -.about-kicker { display: inline-flex; padding: 5px 10px; border-radius: 999px; background: #ffffff14; color: #9ecbff; font-size: 11px; font-weight: 600; letter-spacing: .14em; } -.about-hero h1 { max-width: 640px; margin: 18px 0 12px; color: #fff; font-size: 48px; font-weight: 700; line-height: 1.08; letter-spacing: -.02em; } -.about-hero p { max-width: 600px; margin: 0; color: #cfcfcf; font-size: 15px; line-height: 1.7; } -.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; } -.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 42px; padding: 0 20px; border-radius: 999px; font-size: 14px; font-weight: 600; } -.about-primary { background: #fff; color: #111; } -.about-secondary { border: 1px solid #ffffff2b; background: #ffffff14; color: #fff; } -.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; } -.about-stores-label { color: #8f8f8f; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; } -.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; } -.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 9px 16px; border: 1px solid #ffffff26; border-radius: 12px; background: #ffffff0f; color: #fff; transition: background .18s ease, border-color .18s ease; } -.about-store:hover { border-color: #ffffff4d; background: #ffffff1f; } -.about-store > span { display: flex; flex-direction: column; line-height: 1.2; } -.about-store small { color: #9c9c9c; font-size: 10px; } -.about-store b { color: #fff; font-size: 13px; font-weight: 600; } -.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 26px; } -.about-values span { padding: 5px 10px; border-radius: 999px; background: #ffffff0f; color: #bbb; font-size: 12px; } -.about-hero-visual { display: grid; place-items: center; min-height: 330px; } -.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; width: 188px; height: 322px; border: 1px solid #ffffff24; border-radius: 30px; background: linear-gradient(160deg, #0e2b55, #1b1b1b 60%, #350e19); box-shadow: 0 30px 80px #0008; } -.about-phone::before { content: ''; position: absolute; top: 12px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff38; } -.about-phone-glow { position: absolute; inset: 12px; border-radius: 22px; background: radial-gradient(circle at 50% 30%, #1f6feb33, transparent 60%); } -.about-phone-logo { position: relative; color: #fff; font-size: 34px; font-weight: 800; letter-spacing: -.04em; } -.about-phone-play { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: #ff0033; color: #fff; box-shadow: 0 10px 30px #ff003355; } -.about-phone small { position: relative; color: #9ecbff; font-size: 10px; font-weight: 600; letter-spacing: .24em; } -.about-float { position: absolute; z-index: 2; padding: 6px 10px; border: 1px solid #ffffff26; border-radius: 999px; background: #ffffff17; color: #eaeaea; font-size: 11px; white-space: nowrap; } -.about-float-a { top: 26px; left: 0; } -.about-float-b { top: 50%; right: -4px; } -.about-float-c { bottom: 24px; left: 12px; } -.about-section { margin-top: 40px; } -.about-section > h2 { margin: 0 0 12px; color: #fff; font-size: 24px; font-weight: 600; } -.about-lead { max-width: 760px; margin: 0; color: #b7b7b7; font-size: 15px; line-height: 1.8; } -.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 20px; } -.about-pillar { padding: 24px; border: 1px solid #2a2a2a; border-radius: 16px; background: #1a1a1a; } -.about-pillar-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: #ffffff10; color: #9ecbff; } -.about-pillar h3 { margin: 18px 0 8px; color: #fff; font-size: 17px; font-weight: 600; } -.about-pillar p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; } -.about-roadmap { margin: 16px 0 0; padding: 0; list-style: none; } -.about-roadmap li { display: flex; gap: 18px; padding: 18px 0; border-bottom: 1px solid #262626; } -.about-roadmap li:last-child { border-bottom: 0; } -.about-step-index { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: #ffffff10; color: #9ecbff; font-size: 12px; font-weight: 600; } -.about-roadmap b { display: block; margin-bottom: 4px; color: #fff; font-size: 15px; font-weight: 600; } -.about-roadmap p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; } -.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-top: 44px; padding: 32px; border: 1px solid #2a2a2a; border-radius: 20px; background: linear-gradient(120deg, #12233f, #1b1b1b 55%, #33101a); } -.about-cta h2 { margin: 0 0 8px; color: #fff; font-size: 22px; font-weight: 600; } -.about-cta p { margin: 0; max-width: 560px; color: #b7b7b7; font-size: 14px; line-height: 1.7; } +.about-page { max-width: 1080px; margin: 0 auto; padding: 8px 0 40px; counter-reset: about-section; } -@media (max-width: 960px) { .about-hero { grid-template-columns: 1fr; padding: 40px 32px; } .about-hero-visual { display: none; } .about-hero h1 { font-size: 38px; } } +/* Hero:极光渐变 + 高级质感 */ +.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); gap: 40px; align-items: center; overflow: hidden; padding: 64px 56px; border-radius: 30px; background: radial-gradient(130% 150% at 8% -10%, #182947 0%, #0d0e12 44%, #170b12 100%); box-shadow: 0 50px 130px #0008, inset 0 1px 0 #ffffff12; isolation: isolate; } +.about-hero::before { content: ''; position: absolute; top: -280px; right: -160px; width: 620px; height: 620px; border-radius: 50%; background: radial-gradient(circle, #2f6bff4d, transparent 62%); filter: blur(8px); pointer-events: none; } +.about-hero::after { content: ''; position: absolute; bottom: -320px; left: 18%; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(circle, #ff2d5540, transparent 64%); filter: blur(8px); pointer-events: none; } +.about-hero-copy, .about-hero-visual { position: relative; z-index: 2; } +.about-kicker { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: #ffffff12; color: #bfd8ff; font-size: 11px; font-weight: 700; letter-spacing: .16em; backdrop-filter: blur(8px); } +.about-kicker::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #8ec5ff; box-shadow: 0 0 12px #8ec5ff; animation: about-pulse 2.4s ease-in-out infinite; } +@keyframes about-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } } +.about-hero h1 { max-width: 620px; margin: 20px 0 14px; font-size: 58px; font-weight: 800; line-height: 1.04; letter-spacing: -.035em; background: linear-gradient(115deg, #ffffff 15%, #b9d3ff 52%, #ffb7c6 88%); -webkit-background-clip: text; background-clip: text; color: transparent; } +.about-hero p { max-width: 560px; margin: 0; color: #b6bcc8; font-size: 15.5px; line-height: 1.75; } +.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } +.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 44px; padding: 0 22px; border-radius: 999px; font-size: 14px; font-weight: 700; transition: transform .2s ease, box-shadow .2s ease, background .2s ease; } +.about-primary { background: linear-gradient(120deg, #ffffff, #e8eeff); color: #0b0c10; box-shadow: 0 10px 30px #0008; } +.about-primary:hover { transform: translateY(-2px); box-shadow: 0 16px 40px #000a; } +.about-secondary { background: #ffffff10; color: #fff; backdrop-filter: blur(8px); } +.about-secondary:hover { transform: translateY(-2px); background: #ffffff20; } +.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 30px; } +.about-stores-label { color: #8b90a0; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; } +.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: 14px; background: #ffffff0f; color: #fff; backdrop-filter: blur(8px); transition: background .2s ease, transform .2s ease; } +.about-store:hover { background: #ffffff1f; transform: translateY(-2px); } +.about-store > span { display: flex; flex-direction: column; line-height: 1.2; } +.about-store small { color: #9298a6; font-size: 10px; } +.about-store b { color: #fff; font-size: 13px; font-weight: 700; } +.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; } +.about-values span { padding: 6px 12px; border-radius: 999px; background: #ffffff10; color: #c3c8d4; font-size: 12px; } +.about-hero-visual { display: grid; place-items: center; min-height: 360px; } +.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 200px; height: 350px; border-radius: 34px; background: linear-gradient(165deg, #123264, #14151b 58%, #360f1c); box-shadow: 0 0 0 8px #ffffff06, 0 46px 100px #000b, inset 0 1px 0 #ffffff26; animation: about-float-y 6.5s ease-in-out infinite; } +.about-phone::before { content: ''; position: absolute; top: 13px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff33; } +.about-phone-glow { position: absolute; inset: 14px; border-radius: 24px; background: radial-gradient(circle at 50% 28%, #2f6bff38, transparent 62%); } +.about-phone-logo { position: relative; color: #fff; font-size: 36px; font-weight: 800; letter-spacing: -.04em; } +.about-phone-play { position: relative; display: grid; place-items: center; width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #ff2d55, #d1003a); color: #fff; box-shadow: 0 12px 34px #ff003355, inset 0 1px 0 #ffffff40; } +.about-phone small { position: relative; color: #a9ccff; font-size: 10px; font-weight: 700; letter-spacing: .28em; } +@keyframes about-float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } } +.about-float { position: absolute; z-index: 3; padding: 7px 12px; border-radius: 999px; background: #191a20e6; color: #e9edf5; font-size: 11px; white-space: nowrap; box-shadow: 0 12px 28px #0009; backdrop-filter: blur(10px); } +.about-float-a { top: 22px; left: 4px; animation: about-float-y 7.2s ease-in-out infinite; } +.about-float-b { top: 48%; right: -8px; animation: about-float-y 8s ease-in-out .6s infinite; } +.about-float-c { bottom: 26px; left: 16px; animation: about-float-y 7.6s ease-in-out .3s infinite; } + +/* 内容分区 */ +.about-section { position: relative; margin-top: 64px; } +.about-section::before { counter-increment: about-section; content: '0' counter(about-section); position: absolute; top: -30px; left: -6px; color: #ffffff0d; font-size: 76px; font-weight: 800; line-height: 1; letter-spacing: -.05em; pointer-events: none; } +.about-section > h2 { position: relative; margin: 0 0 14px; color: #fff; font-size: 27px; font-weight: 700; letter-spacing: -.01em; } +.about-lead { position: relative; max-width: 760px; margin: 0; color: #b7bac3; font-size: 15.5px; line-height: 1.85; } +.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-top: 28px; } +.about-pillar { position: relative; overflow: hidden; padding: 28px; border-radius: 20px; background: linear-gradient(180deg, #191a20, #101013); box-shadow: 0 20px 44px #0006; transition: transform .26s ease, box-shadow .26s ease; } +.about-pillar::before { content: ''; position: absolute; top: -60px; right: -40px; width: 160px; height: 160px; border-radius: 50%; background: radial-gradient(circle, #2f6bff2e, transparent 70%); pointer-events: none; } +.about-pillar:hover { transform: translateY(-5px); box-shadow: 0 32px 64px #000b; } +.about-pillar-icon { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: linear-gradient(135deg, #1f6feb38, #ff2d5533); color: #c3dbff; } +.about-pillar h3 { margin: 20px 0 8px; color: #fff; font-size: 17px; font-weight: 700; } +.about-pillar p { margin: 0; color: #a4a8b3; font-size: 13px; line-height: 1.75; } +.about-roadmap { position: relative; margin: 26px 0 0; padding: 0 0 0 26px; list-style: none; } +.about-roadmap::before { content: ''; position: absolute; left: 17px; top: 10px; bottom: 18px; width: 1px; background: linear-gradient(180deg, #4d8dff66, #ff2d5544 55%, transparent); } +.about-roadmap li { display: flex; gap: 18px; padding: 14px 0 20px; } +.about-step-index { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; background: #0f0f13; box-shadow: 0 0 0 1px #ffffff1a, 0 0 20px #2f6bff2e; color: #9ecbff; font-size: 12px; font-weight: 700; } +.about-roadmap b { display: block; margin-bottom: 5px; color: #f2f4f8; font-size: 15px; font-weight: 700; } +.about-roadmap p { margin: 0; color: #a4a8b3; font-size: 13px; line-height: 1.75; } +.about-cta { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; margin-top: 64px; padding: 40px; border-radius: 26px; background: radial-gradient(120% 160% at 0% 0%, #16294a, #101014 52%, #2a0f18); box-shadow: 0 40px 100px #0007, inset 0 1px 0 #ffffff10; } +.about-cta::before { content: ''; position: absolute; top: -160px; right: -80px; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff2d5533, transparent 65%); pointer-events: none; } +.about-cta h2 { position: relative; margin: 0 0 8px; color: #fff; font-size: 26px; font-weight: 700; letter-spacing: -.015em; } +.about-cta p { position: relative; margin: 0; max-width: 560px; color: #b6bac4; font-size: 14px; line-height: 1.75; } +.about-cta .about-primary { position: relative; } + +@media (max-width: 860px) { .about-hero { grid-template-columns: 1fr; padding: 40px 32px; } .about-hero-visual { display: none; } .about-hero h1 { font-size: 38px; } } @media (max-width: 900px) { .short-rail { left: calc(100% + 8px); } } @media (min-width: 1700px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @@ -391,7 +410,9 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .upload-backdrop { padding: 12px; } .upload-dialog { padding: 18px; } .about-page { padding: 4px 0 16px; } - .about-hero { padding: 32px 22px; border-radius: 18px; } + .about-hero { padding: 32px 22px; border-radius: 22px; } + .about-section { margin-top: 44px; } + .about-section::before { top: -20px; font-size: 52px; } .about-hero h1 { font-size: 32px; } .about-hero p { font-size: 14px; } .about-hero-actions { margin-top: 20px; } @@ -408,14 +429,19 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .short-slide-info h1 { font-size: 16px; } .shorts-state { top: 12px; } .watch-page { max-width: none; padding: 0; } - .watch-player, .watch-skeleton-player { width: calc(100% + 24px); margin: 0 -12px; border-radius: 0; } - .watch-title { font-size: 18px; margin: 14px 0 10px; } - .watch-toolbar { gap: 12px; } + .watch-stage { margin: 0 2px; padding: 16px 10px; border-radius: 18px; } + .watch-player { height: min(62vh, 520px); border-radius: 14px; } + .watch-skeleton-player { width: min(100%, 260px); } + .watch-meta { margin: 16px 2px 8px; font-size: 12px; } + .watch-title { font-size: 19px; margin-bottom: 14px; } + .watch-toolbar { gap: 12px; padding: 12px 0; } .watch-channel { width: 100%; } .watch-actions { width: 100%; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; } .watch-actions::-webkit-scrollbar { display: none; } .watch-actions button { flex: none; } - .watch-side { margin-top: 28px; } + .watch-description { padding: 14px 16px; font-size: 13px; } + .watch-side { position: static; margin-top: 24px; padding: 14px; border-radius: 16px; } + .watch-episode-list { max-height: none; } .history-heading h1 { font-size: 20px; } .bottom-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; display: flex; height: 64px; background: #151515; border-top: 1px solid #292929; } .bottom-nav a { flex: 1; display: grid; place-items: center; gap: 3px; color: #aaa; font-size: 10px; line-height: 1.2; } @@ -425,3 +451,86 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He .sidebar.closed { display: none; } .scrim { position: fixed; inset: 0; z-index: 45; background: #0006; border: 0; } } + +/* 路由懒加载占位 */ +.route-loading { min-height: 60vh; } + +/* 自定义视频控件 */ +.vp { position: relative; display: block; width: 100%; height: 100%; overflow: hidden; background: #000; outline: none; } +.vp .video-player { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; } +.vp.is-playing:not(.show-controls) { cursor: none; } +.vp-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; padding: 38px 14px 10px; background: linear-gradient(transparent, #000000bd 42%, #000000f2); opacity: 0; transform: translateY(10px); transition: opacity .24s ease, transform .28s cubic-bezier(.22, .61, .36, 1); pointer-events: none; } +.vp.show-controls .vp-controls { opacity: 1; transform: none; pointer-events: auto; } +.vp.show-controls .vp-progress { animation: vp-rise .34s cubic-bezier(.22, .61, .36, 1) both; } +.vp.show-controls .vp-row { animation: vp-rise .38s cubic-bezier(.22, .61, .36, 1) .05s both; } +@keyframes vp-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } +.vp-progress { position: relative; display: flex; align-items: center; height: 18px; cursor: pointer; touch-action: none; } +.vp-progress-track { position: relative; width: 100%; height: 4px; overflow: hidden; border-radius: 999px; background: #ffffff24; box-shadow: inset 0 1px 2px #0006; transition: height .18s ease; } +.vp-progress:hover .vp-progress-track { height: 6px; } +.vp-buffered { position: absolute; top: 0; bottom: 0; left: 0; background: #ffffff38; transition: width .3s ease; } +.vp-played { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 999px; background: linear-gradient(90deg, #ffffff, #ffd9e2); box-shadow: 0 0 12px #ffffff66; transition: width .12s linear; } +.vp-thumb { position: absolute; top: 50%; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 4px #ffffff1f, 0 0 16px #ffffff73; transform: translate(-50%, -50%) scale(0); transition: transform .2s cubic-bezier(.34, 1.56, .64, 1), box-shadow .2s ease; pointer-events: none; } +.vp-progress:hover .vp-thumb, .vp-progress:active .vp-thumb { transform: translate(-50%, -50%) scale(1); } +.vp-progress-tip { position: absolute; top: -30px; z-index: 5; padding: 3px 8px; border-radius: 7px; background: #0d0d0ff2; color: #fff; font-size: 11px; font-variant-numeric: tabular-nums; transform: translateX(-50%); pointer-events: none; box-shadow: 0 8px 20px #000a; backdrop-filter: blur(8px); animation: vp-tip-in .16s ease both; } +@keyframes vp-tip-in { from { opacity: 0; transform: translate(-50%, 4px); } to { opacity: 1; transform: translate(-50%, 0); } } +.vp-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; color: #fff; } +.vp-btn { position: relative; display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 50%; background: transparent; color: #fff; cursor: pointer; transition: background .2s ease, box-shadow .2s ease, transform .16s cubic-bezier(.34, 1.56, .64, 1); } +.vp-btn svg { transition: transform .18s ease; } +.vp-btn:hover { background: #ffffff22; box-shadow: inset 0 0 0 1px #ffffff1a, 0 4px 14px #0006; } +.vp-btn:hover svg { transform: scale(1.08); } +.vp-btn:active { transform: scale(.9); } +.vp-time { color: #d6d6d6; font-size: 12px; letter-spacing: .01em; font-variant-numeric: tabular-nums; } +.vp-spacer { flex: 1; } +.vp-volume { position: relative; display: flex; align-items: center; } +.vp-volume-slider { position: absolute; left: 50%; bottom: 44px; z-index: 5; display: flex; align-items: center; width: 104px; height: 34px; padding: 0 12px; border-radius: 999px; background: #0d0d0ff2; box-shadow: 0 16px 36px #000c, inset 0 1px 0 #ffffff14; opacity: 0; visibility: hidden; transform: translate(-50%, 8px) scale(.94); transition: opacity .18s ease, transform .24s cubic-bezier(.34, 1.56, .64, 1), visibility .18s; backdrop-filter: blur(12px); } +.vp-volume-slider.open { opacity: 1; visibility: visible; transform: translate(-50%, 0) scale(1); } +.vp-volume-slider input { width: 100%; height: 3px; border-radius: 999px; background: #ffffff3d; outline: none; cursor: pointer; -webkit-appearance: none; appearance: none; } +.vp-volume-slider input::-webkit-slider-thumb { width: 10px; height: 10px; border: 0; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px #ffffff26; -webkit-appearance: none; appearance: none; } +.vp-spinner { position: absolute; inset: 0; z-index: 2; width: 36px; height: 36px; margin: auto; border: 3px solid #ffffff26; border-top-color: #fff; border-radius: 50%; filter: drop-shadow(0 0 10px #ffffff40); animation: vp-spin .8s linear infinite; } +@keyframes vp-spin { to { transform: rotate(360deg); } } +.vp-big-play { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; width: 64px; height: 64px; margin: auto; border: 0; border-radius: 50%; background: #000000a6; color: #fff; backdrop-filter: blur(8px); cursor: pointer; transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), background .2s ease, box-shadow .2s ease; animation: vp-pop .3s cubic-bezier(.34, 1.56, .64, 1) both; } +.vp-big-play:hover { transform: scale(1.08); background: #000000cc; box-shadow: 0 0 0 8px #ffffff14, 0 16px 40px #000a; } +.vp-big-play:active { transform: scale(.96); } +@keyframes vp-pop { from { opacity: 0; transform: scale(.82); } to { opacity: 1; transform: scale(1); } } +.vp-error { position: absolute; left: 50%; bottom: 54px; z-index: 4; max-width: 82%; padding: 10px 16px; border-radius: 999px; background: #241919ee; color: #ffd4cf; font-size: 12px; cursor: pointer; transform: translateX(-50%); backdrop-filter: blur(8px); } +@media (max-width: 760px) { + .vp-controls { padding: 30px 10px 8px; } + .vp-row { gap: 4px; } + .vp-btn { width: 36px; height: 36px; } + .vp-big-play { width: 56px; height: 56px; } +} +@media (prefers-reduced-motion: reduce) { + .vp-controls, .vp-btn, .vp-btn svg, .vp-thumb, .vp-played, .vp-buffered, .vp-big-play, .vp-volume-slider { transition: none; } + .vp.show-controls .vp-progress, .vp.show-controls .vp-row, .vp-big-play, .vp-progress-tip { animation: none; } + .about-phone, .about-float-a, .about-float-b, .about-float-c, .about-kicker::before { animation: none; } +} + +/* 点赞 / 收藏反馈特效 */ +@keyframes ymb-like-pop { + 0% { transform: scale(1); } + 30% { transform: scale(1.34) rotate(-7deg); } + 62% { transform: scale(.92) rotate(3deg); } + 100% { transform: scale(1) rotate(0); } +} +@keyframes ymb-like-ring { + 0% { opacity: .5; transform: scale(.96); } + 100% { opacity: 0; transform: scale(1.32); } +} +@keyframes ymb-like-count { + 0% { transform: translateY(0); } + 35% { transform: translateY(-5px); } + 100% { transform: translateY(0); } +} +.watch-actions button, .short-rail button { position: relative; } +.watch-actions button.active svg, +.short-rail button.active .rail-icon svg { animation: ymb-like-pop .46s cubic-bezier(.34, 1.56, .64, 1); } +.watch-actions button.active > span, +.short-rail button.active small { display: inline-block; animation: ymb-like-count .46s ease; } +.watch-actions button.active::after { content: ''; position: absolute; inset: 0; border-radius: inherit; border: 1px solid #fff; pointer-events: none; animation: ymb-like-ring .5s ease-out forwards; } +.short-rail .rail-icon { position: relative; } +.short-rail button.active .rail-icon::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1px solid #fff; pointer-events: none; animation: ymb-like-ring .5s ease-out forwards; } +@media (prefers-reduced-motion: reduce) { + .watch-actions button.active svg, .short-rail button.active .rail-icon svg, + .watch-actions button.active > span, .short-rail button.active small, + .watch-actions button.active::after, .short-rail button.active .rail-icon::after { animation: none; } +} diff --git a/styles.css b/styles.css deleted file mode 100644 index 2fc01eb..0000000 --- a/styles.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Author: fruit.com.cn */ -.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.language-select select{border:1px solid #363636;border-radius:16px;background:#1d1d1d;color:#ddd;padding:6px 9px;font-size:11px} -:root{--bg:#0f0f0f;--surface:#181818;--line:#303030;--text:#f1f1f1;--muted:#aaa;--accent:#ff4b3e;--sidebar:230px}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px Arial,sans-serif}a{color:inherit;text-decoration:none}button,input{font:inherit}.topbar{height:64px;position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:24px;padding:0 28px;background:#0f0f0ff2;border-bottom:1px solid #222}.brand{display:flex;align-items:center;gap:8px;font-size:26px;font-weight:700;min-width:150px}.brand-symbol{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--accent);font-size:12px}.search-form{display:flex;max-width:620px;flex:1;margin:auto}.search-form input{width:100%;height:40px;padding:0 16px;border:1px solid #3b3b3b;border-radius:22px 0 0 22px;background:#121212;color:#fff}.search-form button{width:54px;border:1px solid #3b3b3b;border-left:0;border-radius:0 22px 22px 0;background:#282828;color:#fff;font-size:24px}.top-actions{display:flex;align-items:center;gap:10px}.icon-button{border:0;background:transparent;color:#fff;font-size:22px;cursor:pointer}.profile-chip{border:0;border-radius:50%;width:32px;height:32px;background:#d45a68;color:#fff}.sidebar{position:fixed;top:64px;bottom:0;width:var(--sidebar);padding:20px 12px;background:var(--bg);border-right:1px solid #222}.main-nav,.genre-nav{display:grid;gap:4px}.nav-item,.genre-nav a{display:flex;gap:18px;align-items:center;padding:11px 14px;border-radius:10px;color:#ccc}.nav-item:hover,.nav-item.active,.genre-nav a:hover{background:#272727;color:#fff}.nav-item span{width:18px;text-align:center;font-size:20px}.nav-item b{font-weight:400}.nav-rule{height:1px;background:var(--line);margin:18px 10px}.nav-label{padding:0 14px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.sidebar-footer{position:absolute;bottom:24px;padding:0 14px;color:#666;font-size:11px;line-height:1.8}.content{max-width:1500px;padding:28px 34px 80px calc(var(--sidebar) + 34px);margin:auto}.hero-card{position:relative;min-height:350px;overflow:hidden;border-radius:18px;background:#27191a;margin-bottom:44px}.hero-copy{position:relative;z-index:1;width:52%;padding:54px}.eyebrow{color:#ff786f;font-size:11px;font-weight:700;letter-spacing:.16em}.hero-copy h1{margin:12px 0;font-size:56px;line-height:.98}.hero-copy h1 em{color:#ff786f;font-style:normal}.hero-copy p{max-width:390px;color:#ccc;line-height:1.7}.hero-art{position:absolute;inset:0 0 0 40%}.hero-art img{width:100%;height:100%;object-fit:cover}.hero-gradient{position:absolute;inset:0;background:linear-gradient(90deg,#27191a,rgba(39,25,26,.75) 26%,transparent 80%)}.hero-actions{display:flex;gap:10px}.primary-button,.ghost-button,.chip{border:0;border-radius:22px;padding:11px 17px;cursor:pointer;font-weight:600}.primary-button{background:#fff;color:#111}.ghost-button,.chip{background:#2b2b2b;color:#fff}.section-block{margin-bottom:46px}.section-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:17px}.section-heading h2{margin:7px 0 0;font-size:24px}.see-all{color:#ff8a82;font-size:13px}.chip-row{display:flex;gap:8px;overflow:auto;margin-bottom:18px}.chip.active,.chip:hover{background:#fff;color:#111}.video-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px 16px}.thumb{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#222}.thumb img,.short-card img{width:100%;height:100%;object-fit:cover}.duration{position:absolute;right:7px;bottom:7px;padding:3px 6px;background:#000c;border-radius:4px;font-size:11px}.video-meta{display:flex;gap:9px;padding-top:9px}.channel-avatar{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:50%;background:#734c67;font-weight:700}.video-title{margin:0;line-height:1.4;font-weight:600}.video-sub{margin:4px 0 0;color:#999;font-size:11px;line-height:1.45}.shorts-row{display:grid;grid-template-columns:repeat(6,1fr);gap:14px}.short-card{position:relative;aspect-ratio:9/14;overflow:hidden;border-radius:11px}.short-card:after{content:'';position:absolute;inset:45% 0 0;background:linear-gradient(transparent,#000c)}.short-title{position:absolute;z-index:1;bottom:10px;left:9px;right:9px;font-size:12px;font-weight:600}.channel-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.channel-card{display:flex;align-items:center;gap:10px;padding:14px;border:1px solid var(--line);border-radius:11px}.channel-card p{margin:0 0 4px;font-weight:600}.channel-card span{color:#888;font-size:11px}.follow-button{margin-left:auto;border:0;border-radius:18px;padding:8px 11px;background:#fff;color:#111}.bottom-nav,.mobile-only{display:none}.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;background:#fff;color:#111;padding:11px 16px;border-radius:7px;transition:.2s}.toast.show{opacity:1;transform:translate(-50%,0)} -@media(max-width:1100px){.video-grid{grid-template-columns:repeat(3,1fr)}.shorts-row{grid-template-columns:repeat(5,1fr)}.channel-grid{grid-template-columns:repeat(2,1fr)}} -@media(max-width:760px){.topbar{height:58px;padding:0 14px;gap:12px}.mobile-only{display:block}.desktop-only,.sidebar{display:none}.brand{min-width:auto;font-size:23px}.search-form{position:absolute;top:65px;left:14px;right:14px;display:none}.search-form.open{display:flex}.content{padding:20px 14px 90px}.hero-card{min-height:420px;margin:0 -14px 36px;border-radius:0;display:flex;align-items:flex-end}.hero-copy{width:100%;padding:24px}.hero-copy h1{font-size:42px}.hero-art{inset:0}.hero-gradient{background:linear-gradient(0deg,#27191a,rgba(39,25,26,.45) 55%,transparent)}.section-heading h2{font-size:21px}.video-grid{grid-template-columns:repeat(2,1fr);gap:22px 12px}.shorts-row{display:flex;overflow:auto;margin-right:-14px}.short-card{flex:0 0 130px}.channel-grid{grid-template-columns:1fr}.bottom-nav{position:fixed;display:flex;justify-content:space-around;align-items:center;left:0;right:0;bottom:0;height:66px;z-index:3;background:#151515;border-top:1px solid #292929}.bottom-nav a{display:grid;place-items:center;gap:4px;color:#aaa;font-size:10px}.bottom-nav span{font-size:20px}.bottom-nav .active{color:#fff}.bottom-nav .active span{color:var(--accent)}} diff --git a/wedding.jpg b/wedding.jpg deleted file mode 100644 index 67da900..0000000 Binary files a/wedding.jpg and /dev/null differ