Compare commits
6
Commits
9d9c7e3eec
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa9f311e0 | ||
|
|
d6da152dec | ||
|
|
3d96d82300 | ||
|
|
ebf7886485 | ||
|
|
535bb57c83 | ||
|
|
0ab0b04bbe |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# YMB 官网高级 UI 与交互优化计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 在不改变现有深色品牌基调、真实接口和信息架构的前提下,提升官网的视觉质感与交互连贯性。
|
||||
- 桌面端重点优化导航层级、内容卡片反馈、搜索与语言切换;移动端重点优化触控尺寸、抽屉与沉浸式内容浏览。
|
||||
- 保证加载、空态、错误态与播放状态都有明确反馈,并尊重系统减少动画设置。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 审计首页、Shorts、观看页、搜索页在桌面与移动视口下的布局和控制台状态。
|
||||
2. 修复影响内容展示、可访问性和交互流畅度的问题。
|
||||
3. 统一视觉 token、悬停/按压/焦点反馈、卡片动效、骨架加载与移动端安全区适配。
|
||||
4. 验证真实接口、图片、Shorts 播放和页面跳转不回退。
|
||||
5. 运行构建与差异检查,回填验证结果。
|
||||
|
||||
## 范围
|
||||
|
||||
- `src/components/**`
|
||||
- `src/layouts/**`
|
||||
- `src/pages/**`
|
||||
- `src/styles/**`
|
||||
- `src/app/**`
|
||||
- 必要的测试与本计划文件
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- 不修改 FruitServer 接口契约,不把匿名浏览改成登录后可见。
|
||||
- 不改变用户已确认的深色背景与品牌主色,只调整层次、间距、透明度和反馈。
|
||||
- 不引入重量级 UI 框架,优先复用现有组件与 CSS token。
|
||||
- 当前分支为 `dev`,只提交本轮站点文件,不夹带其他仓库改动。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm build`
|
||||
- `git diff --check`
|
||||
- 桌面端 1440px:首页、Shorts、观看页、搜索与语言菜单
|
||||
- 移动端 390×844:侧栏抽屉、Shorts 滑动、观看页与安全区
|
||||
- 浏览器控制台无新增 error
|
||||
|
||||
## 实施回执
|
||||
|
||||
- 已完成:保留深色 YMB 品牌、真实接口和无底部导航约束,未修改 FruitServer 契约。
|
||||
- 样式:移除全局 `font-weight: 400 !important`,恢复标题/按钮/元信息层级;增加页面最小宽度与横向溢出保护;移动端双列网格和 Shorts 横向列表增加宽度约束。
|
||||
- 交互:补充键盘 `:focus-visible` 焦点环、搜索聚焦反馈、触屏按压反馈;创建菜单/语言菜单增加轻量入场动画;侧栏与内容区收起/展开增加平滑过渡;尊重 `prefers-reduced-motion`。
|
||||
- 真实链路复核:前端 `http://127.0.0.1:4179/`、后端 `/app/categories`、`/app/home/feed`、`/app/dramas/1`、`/app/episodes/1/play` 均可达;播放地址响应 `application/vnd.apple.mpegurl`,图片 `hero.jpg` 响应 `image/jpeg`。
|
||||
- 门禁:`pnpm lint` 通过;`pnpm build` 通过(仅保留既有 chunk size warning);`git diff --check` 通过。
|
||||
- 当前状态:本轮文件已提交并推送到 `origin/dev`(提交 `0ab0b04`);未改后端、未引入底部导航,已发送完成通知。
|
||||
@@ -0,0 +1,42 @@
|
||||
# YMB 站点运行态与 Shorts 体验优化计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 让 Shorts 与观看页统一使用 `/app/episodes/{id}/play` 的最新签名播放地址。
|
||||
- 优化播放等待、播放失败、空结果和移动端手势反馈,减少“像演示数据”的体验。
|
||||
- 保持现有深色品牌、YouTube 式信息架构、多语言、匿名浏览和无底部导航。
|
||||
|
||||
## 范围
|
||||
|
||||
- `src/features/shorts/**`
|
||||
- `src/shared/components/player/**`
|
||||
- `src/features/search/**`
|
||||
- 必要的共享样式与本计划文件
|
||||
|
||||
## 约束
|
||||
|
||||
- 不修改 FruitServer 接口契约,不自行拼接签名播放地址。
|
||||
- 播放地址只使用接口返回值;预览流只作为接口失败时的兼容回退。
|
||||
- 不引入新的状态管理或重量级 UI 依赖。
|
||||
- 保持当前分支 `dev`,只提交 YMB 子项目本轮文件。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm lint`
|
||||
- `pnpm build`
|
||||
- `git diff --check`
|
||||
- 本地验证 `/app/feed`、`/app/episodes/{id}/play`、HLS 清单响应和前端首页可访问。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- Shorts 每次激活视频会请求一次播放票据;使用现有请求取消机制,避免切换页面后写入失效状态。
|
||||
- 当前浏览器自动播放策略仍可能要求静音启动,保留现有声音提示和手动开启逻辑。
|
||||
|
||||
## 实施回执
|
||||
|
||||
- 已完成:Shorts 当前激活视频改为调用 `/app/episodes/{id}/play` 获取最新签名地址,切换激活项时复用 `useRequest` 的取消机制;接口失败时仅回退到推荐流已有播放地址。
|
||||
- 媒体解析:图片 `resolveMedia` 与播放 `resolvePlaybackUrl` 分离,缺失播放地址返回空字符串,不再把 `hero.jpg` 送入 HLS;锁定集和无地址分集保持占位状态。
|
||||
- 交互:Shorts 当前视频播放结束后自动进入下一条,手势、键盘方向键和桌面箭头控制保持不变;等待和播放失败状态在卡片内可见。
|
||||
- 真实链路:`/app/feed?size=6` 返回 6 条并带游标;首条 `/app/episodes/{id}/play` 返回 `accessible=true`、签名 `playUrl` 和多清晰度;HLS 清单响应 `application/vnd.apple.mpegurl`;前端 `http://127.0.0.1:4179/` 返回 200。
|
||||
- 门禁:`pnpm lint` 通过;`pnpm build` 通过(仅有既有大 chunk 提示);`git diff --check` 通过。
|
||||
- 当前状态:本轮源码与计划文件已提交并推送到 `origin/dev`(提交 `ebf7886`);未修改 FruitServer 契约,未增加底部导航。
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://ymb.example.com/?lang=id</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://ymb.example.com/?lang=en</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://ymb.example.com/?lang=zh</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>http://39.162.50.99:3007/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>http://39.162.50.99:3007/about</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>http://39.162.50.99:3007/privacy</loc><changefreq>yearly</changefreq><priority>0.5</priority></url>
|
||||
<url><loc>http://39.162.50.99:3007/terms</loc><changefreq>yearly</changefreq><priority>0.5</priority></url>
|
||||
</urlset>
|
||||
@@ -10,6 +10,7 @@ const SearchPage = lazy(() => import('../features/search/SearchPage').then((modu
|
||||
const HistoryPage = lazy(() => import('../features/history/HistoryPage').then((module) => ({ default: module.HistoryPage })));
|
||||
const ListingPage = lazy(() => import('../features/listing/ListingPage').then((module) => ({ default: module.ListingPage })));
|
||||
const AboutPage = lazy(() => import('../features/about/AboutPage').then((module) => ({ default: module.AboutPage })));
|
||||
const LegalPage = lazy(() => import('../features/legal/LegalPage').then((module) => ({ default: module.LegalPage })));
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
@@ -21,6 +22,9 @@ export function AppRouter() {
|
||||
<Route path="/library" element={<ListingPage type="library" />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/privacy" element={<LegalPage type="privacy" />} />
|
||||
<Route path="/privacy-policy" element={<LegalPage type="privacy" />} />
|
||||
<Route path="/terms" element={<LegalPage type="terms" />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/watch/:id" element={<WatchPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
import { getImage, resolveMedia } from '../../shared/lib/media';
|
||||
import { getImage, resolveMedia, resolvePlaybackUrl } from '../../shared/lib/media';
|
||||
import { getText } from '../../shared/lib/text';
|
||||
|
||||
export function normalizeDrama(item = {}, lang = 'id') {
|
||||
@@ -18,8 +18,8 @@ export function normalizeDrama(item = {}, lang = 'id') {
|
||||
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,
|
||||
episodes: episodes.map((episode) => ({ ...episode, playUrl: resolvePlaybackUrl(episode.playUrl) })),
|
||||
preview: preview ? { ...preview, playUrl: resolvePlaybackUrl(preview.playUrl) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ArrowRight, Clapperboard, Cpu, Play, Sparkles, Users } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { COMPANY } from '../../shared/data/company';
|
||||
import { aboutContent } from './content';
|
||||
|
||||
const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG';
|
||||
@@ -12,14 +13,6 @@ const PILLARS = [
|
||||
{ key: 'compute', icon: Cpu },
|
||||
];
|
||||
|
||||
function AppleLogo() {
|
||||
return (
|
||||
<svg width="22" height="24" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
|
||||
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GooglePlayLogo() {
|
||||
return (
|
||||
<svg width="22" height="24" viewBox="0 0 512 512" aria-hidden="true">
|
||||
@@ -31,6 +24,36 @@ function GooglePlayLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
function CompanySection({ copy }) {
|
||||
return (
|
||||
<section className="about-section">
|
||||
<h2>{copy.companyTitle}</h2>
|
||||
<div className="company-card">
|
||||
<div className="company-item">
|
||||
<span>{copy.companyLegalName}</span>
|
||||
<strong>{COMPANY.legalName}</strong>
|
||||
</div>
|
||||
<div className="company-item">
|
||||
<span>{copy.companyAddress}</span>
|
||||
<strong>{COMPANY.address}</strong>
|
||||
</div>
|
||||
<div className="company-item">
|
||||
<span>{copy.companyRegistration}</span>
|
||||
<strong>NIB {COMPANY.nib}</strong>
|
||||
</div>
|
||||
<div className="company-item">
|
||||
<span>{copy.companyEmail}</span>
|
||||
<a href={`mailto:${COMPANY.email}`}>{COMPANY.email}</a>
|
||||
</div>
|
||||
<div className="company-item">
|
||||
<span>{copy.companyPhone}</span>
|
||||
<a href={`tel:${COMPANY.phoneHref}`}>{COMPANY.phone}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const { locale } = useLocale();
|
||||
const copy = aboutContent[locale.code] || aboutContent.en;
|
||||
@@ -49,10 +72,6 @@ export function AboutPage() {
|
||||
<div className="about-stores">
|
||||
<span className="about-stores-label">{copy.downloadLabel}</span>
|
||||
<div className="about-stores-row">
|
||||
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
|
||||
<AppleLogo />
|
||||
<span className="store-badge-copy"><small>{copy.iosSmall}</small><b>App Store</b></span>
|
||||
</a>
|
||||
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
|
||||
<GooglePlayLogo />
|
||||
<span className="store-badge-copy"><small>{copy.androidSmall}</small><b>Google Play</b></span>
|
||||
@@ -79,6 +98,8 @@ export function AboutPage() {
|
||||
<p className="about-lead">{copy.whoText}</p>
|
||||
</section>
|
||||
|
||||
<CompanySection copy={copy} />
|
||||
|
||||
<section className="about-section">
|
||||
<h2>{copy.pillarsTitle}</h2>
|
||||
<div className="about-pillars">
|
||||
|
||||
@@ -12,6 +12,12 @@ export const aboutContent = {
|
||||
values: ['Mobile-first', 'Multilingual', 'Open platform'],
|
||||
whoTitle: 'Who we are',
|
||||
whoText: 'YMB brings vertical, fast-paced dramas to audiences around the world. We localize stories across languages, connect them with the right viewers, and keep the experience light, mobile-first, and free to explore.',
|
||||
companyTitle: 'Company information',
|
||||
companyLegalName: 'Legal entity',
|
||||
companyAddress: 'Registered address',
|
||||
companyRegistration: 'Business registration',
|
||||
companyEmail: 'Contact email',
|
||||
companyPhone: 'Contact phone',
|
||||
pillarsTitle: 'Where YMB is going',
|
||||
joinTitle: 'Creator onboarding',
|
||||
joinText: 'Studios and independent creators will be able to join YMB, publish their work, and grow with the platform.',
|
||||
@@ -42,6 +48,12 @@ export const aboutContent = {
|
||||
values: ['Mobile-first', 'Multibahasa', 'Platform terbuka'],
|
||||
whoTitle: 'Siapa kami',
|
||||
whoText: 'YMB menghadirkan drama vertikal yang cepat ke penonton di seluruh dunia. Kami melokalkan cerita, menghubungkannya dengan penonton yang tepat, dan menjaga pengalaman tetap ringan, mobile-first, dan gratis untuk dijelajahi.',
|
||||
companyTitle: 'Informasi perusahaan',
|
||||
companyLegalName: 'Badan hukum',
|
||||
companyAddress: 'Alamat terdaftar',
|
||||
companyRegistration: 'Registrasi bisnis',
|
||||
companyEmail: 'Email kontak',
|
||||
companyPhone: 'Telepon kontak',
|
||||
pillarsTitle: 'Arah YMB berikutnya',
|
||||
joinTitle: 'Bergabung kreator',
|
||||
joinText: 'Studio dan kreator independen akan dapat bergabung dengan YMB, mempublikasikan karya, dan tumbuh bersama platform.',
|
||||
@@ -72,6 +84,12 @@ export const aboutContent = {
|
||||
values: ['移动优先', '多语言', '开放平台'],
|
||||
whoTitle: '我们是谁',
|
||||
whoText: 'YMB 把节奏明快、适合竖屏观看的短剧带给全球观众。我们做多语言本地化、内容分发与轻量化的移动观影体验,让好故事被更多人看到。',
|
||||
companyTitle: '公司信息',
|
||||
companyLegalName: '法定主体',
|
||||
companyAddress: '注册地址',
|
||||
companyRegistration: '商业登记',
|
||||
companyEmail: '联系邮箱',
|
||||
companyPhone: '联系电话',
|
||||
pillarsTitle: 'YMB 的下一步',
|
||||
joinTitle: '创作者入驻',
|
||||
joinText: '影视公司与独立创作者将可以入驻 YMB,发布作品,并与平台一起成长。',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { COMPANY } from '../../shared/data/company';
|
||||
import { legalContent } from './legalContent';
|
||||
|
||||
function LegalSection({ section, index }) {
|
||||
return (
|
||||
<section className="legal-section">
|
||||
<h2>{index + 1}. {section.title}</h2>
|
||||
{section.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
{section.bullets && (
|
||||
<ul>{section.bullets.map((bullet) => <li key={bullet}>{bullet}</li>)}</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalPage({ type }) {
|
||||
const { locale } = useLocale();
|
||||
const localized = legalContent[type];
|
||||
const copy = localized[locale.code] || localized.en;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${copy.title} · YMB`;
|
||||
}, [copy.title]);
|
||||
|
||||
return (
|
||||
<article className="legal-page">
|
||||
<header className="legal-header">
|
||||
<span className="legal-kicker">YMB · LEGAL</span>
|
||||
<h1>{copy.title}</h1>
|
||||
<p className="legal-effective">{copy.effective}</p>
|
||||
<p className="legal-summary">{copy.summary}</p>
|
||||
<nav className="legal-nav" aria-label="Legal documents">
|
||||
<Link className={type === 'privacy' ? 'active' : ''} to="/privacy">{copy.privacyLabel}</Link>
|
||||
<Link className={type === 'terms' ? 'active' : ''} to="/terms">{copy.termsLabel}</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<div className="legal-body">
|
||||
{copy.sections.map((section, index) => (
|
||||
<LegalSection key={section.title} section={section} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<footer className="legal-contact">
|
||||
<h2>{copy.contactTitle}</h2>
|
||||
<p><strong>{COMPANY.legalName}</strong><br />{COMPANY.address}</p>
|
||||
<p>
|
||||
<a href={`mailto:${COMPANY.email}`}>{COMPANY.email}</a><br />
|
||||
<a href={`tel:${COMPANY.phoneHref}`}>{COMPANY.phone}</a>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Author: fruit.com.cn */
|
||||
.company-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
margin-top: 24px;
|
||||
border: 1px solid #ffffff12;
|
||||
border-radius: 20px;
|
||||
background: #ffffff12;
|
||||
}
|
||||
.company-item { padding: 24px; background: #121318; }
|
||||
.company-item:first-child { grid-column: 1 / -1; background: linear-gradient(120deg, #17243b, #121318); }
|
||||
.company-item span { display: block; margin-bottom: 8px; color: #8f96a5; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.company-item strong, .company-item a { color: #eef2fa; font-size: 15px; font-weight: 600; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.company-item a:hover { color: #a9ccff; }
|
||||
.legal-page { max-width: 880px; margin: 0 auto; padding: 24px 0 56px; }
|
||||
.legal-header { padding: 44px; border: 1px solid #ffffff10; border-radius: 26px; background: radial-gradient(120% 160% at 0% 0%, #172943, #111217 58%, #251018); }
|
||||
.legal-kicker { color: #9ecbff; font-size: 11px; font-weight: 700; letter-spacing: .16em; }
|
||||
.legal-header h1 { margin: 16px 0 10px; color: #fff; font-size: clamp(34px, 5vw, 52px); line-height: 1.1; letter-spacing: -.035em; }
|
||||
.legal-effective { margin: 0 0 22px; color: #8f96a5; font-size: 13px; }
|
||||
.legal-summary { max-width: 730px; margin: 0; color: #c2c7d1; font-size: 15px; line-height: 1.85; }
|
||||
.legal-nav { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
|
||||
.legal-nav a { padding: 9px 15px; border-radius: 999px; background: #ffffff0d; color: #c7ccd6; font-size: 13px; }
|
||||
.legal-nav a:hover, .legal-nav a.active { background: #f1f5ff; color: #111318; }
|
||||
.legal-body { margin-top: 22px; padding: 12px 44px; border: 1px solid #ffffff0d; border-radius: 24px; background: #111216; }
|
||||
.legal-section { margin: 0; padding: 28px 0; border-bottom: 1px solid #ffffff0d; }
|
||||
.legal-section:last-child { border-bottom: 0; }
|
||||
.legal-section h2, .legal-contact h2 { margin: 0 0 14px; color: #f5f7fb; font-size: 19px; line-height: 1.45; }
|
||||
.legal-section p, .legal-section li, .legal-contact p { color: #b4bac5; font-size: 14px; line-height: 1.85; }
|
||||
.legal-section p { margin: 10px 0 0; }
|
||||
.legal-section ul { margin: 12px 0 0; padding-left: 22px; }
|
||||
.legal-section li { margin: 7px 0; padding-left: 4px; }
|
||||
.legal-contact { margin-top: 22px; padding: 30px 44px; border-radius: 22px; background: #171a22; }
|
||||
.legal-contact p { margin: 8px 0; overflow-wrap: anywhere; }
|
||||
.legal-contact a { color: #a9ccff; }
|
||||
.site-footer-links { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; margin-top: 10px; }
|
||||
.site-footer-links a { color: #a0a0a0; }
|
||||
.site-footer-links a:hover { color: #fff; text-decoration: underline; }
|
||||
@media (max-width: 640px) {
|
||||
.company-card { grid-template-columns: 1fr; }
|
||||
.company-item:first-child { grid-column: auto; }
|
||||
.company-item { padding: 20px; }
|
||||
.legal-page { padding-top: 4px; }
|
||||
.legal-header { padding: 28px 22px; border-radius: 20px; }
|
||||
.legal-body { padding: 6px 22px; }
|
||||
.legal-section { padding: 22px 0; }
|
||||
.legal-contact { padding: 24px 22px; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Author: fruit.com.cn
|
||||
import { COMPANY } from '../../shared/data/company';
|
||||
|
||||
const labels = {
|
||||
en: { privacyLabel: 'Privacy Policy', termsLabel: 'Terms of Service', contactTitle: 'Contact us' },
|
||||
id: { privacyLabel: 'Kebijakan Privasi', termsLabel: 'Ketentuan Layanan', contactTitle: 'Hubungi kami' },
|
||||
};
|
||||
|
||||
export const legalContent = {
|
||||
privacy: {
|
||||
en: {
|
||||
...labels.en,
|
||||
title: 'Privacy Policy',
|
||||
effective: 'Effective date: November 13, 2026',
|
||||
summary: `This Privacy Policy explains how ${COMPANY.legalName} (“YMB”, “we”, “us”) collects, uses, discloses, retains, and protects personal data when you use the YMB website, mobile applications, and related services (collectively, the “Service”).`,
|
||||
sections: [
|
||||
{ title: 'Data we collect', paragraphs: ['We collect only the data needed to provide, secure, and improve the Service. The data varies depending on the features you use.'], bullets: [
|
||||
'Account data: name, email address, phone number, password credentials where applicable, profile information, and account identifiers.',
|
||||
'Google Sign-In data: your Google account identifier, name, email address, and profile image when you choose to sign in with Google. We do not receive your Google password.',
|
||||
'Usage data: watch history, viewing progress, searches, likes, comments, follows, device interactions, and approximate session information.',
|
||||
'Transaction data: Google Play product identifiers, purchase tokens, order status, coin balance changes, subscription status, and support records. Google processes your payment method; we do not store your full card number.',
|
||||
'Device and diagnostic data: device model, operating system, app version, language, IP address, log records, crash reports, and approximate region derived from IP.',
|
||||
'Content and communications: videos, comments, reports, requests, and other material you submit, including messages sent to customer support.',
|
||||
] },
|
||||
{ title: 'How we use personal data', bullets: [
|
||||
'Create and administer your account and authenticate your sign-in.',
|
||||
'Provide video playback, continue-watching, recommendations, search, social features, and creator functions.',
|
||||
'Verify Google Play purchases, deliver coins or subscriptions, prevent duplicate fulfilment, handle refunds, and reconcile orders.',
|
||||
'Maintain security, prevent fraud and abuse, debug errors, measure performance, and improve content and features.',
|
||||
'Respond to support requests, send service notices, and comply with legal obligations.',
|
||||
] },
|
||||
{ title: 'Legal bases', paragraphs: ['Where applicable, we process personal data based on your consent, the performance of our contract with you, our legitimate interests in operating and securing the Service, and compliance with legal obligations. You may withdraw consent for future processing, subject to legal and contractual restrictions.'] },
|
||||
{ title: 'When we share data', paragraphs: ['We do not sell your personal data. We may share limited data with:'], bullets: [
|
||||
'Google for Google Sign-In, Google Play distribution, billing, purchase verification, and related platform services.',
|
||||
'Hosting, cloud storage, content delivery, analytics, security, and customer-support providers acting on our instructions.',
|
||||
'Professional advisers, regulators, courts, or law-enforcement bodies where disclosure is legally required or necessary to protect rights and safety.',
|
||||
'A successor in a merger, financing, reorganisation, or asset transfer, subject to appropriate confidentiality and data-protection safeguards.',
|
||||
] },
|
||||
{ title: 'International processing', paragraphs: ['Service providers may process data outside Indonesia. Where we transfer personal data internationally, we use safeguards required by applicable law and take reasonable steps to ensure an adequate level of protection.'] },
|
||||
{ title: 'Retention', paragraphs: ['We retain personal data only for as long as needed for the purposes described above, including account operation, transaction records, dispute resolution, security, and legal compliance. Retention periods vary by data type. When data is no longer required, we delete, anonymise, or securely isolate it.'] },
|
||||
{ title: 'Security', paragraphs: ['We use reasonable administrative, technical, and organisational safeguards, including access controls and encrypted transport where supported. No system is completely secure; please protect your credentials and notify us of suspected unauthorised access.'] },
|
||||
{ title: 'Your rights', paragraphs: ['Subject to Indonesian Law No. 27 of 2022 on Personal Data Protection and other applicable law, you may request access, correction, deletion, restriction, withdrawal of consent, objection, or portability of your personal data. We may verify your identity before responding and may retain records that the law requires or permits.'] },
|
||||
{ title: 'Account and data deletion', paragraphs: [`To request deletion of your YMB account and associated personal data, email ${COMPANY.email} from your registered email or contact us through the Service. Include enough information for us to identify the account, but never send your password. We will verify the request and delete or anonymise data unless retention is required for transactions, fraud prevention, disputes, or legal obligations. Uninstalling the app alone does not delete your account.`] },
|
||||
{ title: 'Children', paragraphs: ['The Service is not directed to children under 13. Where local law requires a higher age for independent consent, a parent or guardian must provide consent. If we learn that we collected a child’s data without required consent, we will take reasonable steps to delete it.'] },
|
||||
{ title: 'Third-party services', paragraphs: ['Google and other third-party services process data under their own privacy policies. You can manage your Google account permissions through your Google account settings. Links to third-party sites do not imply responsibility for their practices.'] },
|
||||
{ title: 'Changes to this policy', paragraphs: ['We may update this policy to reflect changes in the Service or legal requirements. We will publish the revised policy with a new effective date and provide additional notice where required. Continued use after the effective date is subject to the revised policy.'] },
|
||||
],
|
||||
},
|
||||
id: {
|
||||
...labels.id,
|
||||
title: 'Kebijakan Privasi',
|
||||
effective: 'Tanggal berlaku: 13 November 2026',
|
||||
summary: `Kebijakan Privasi ini menjelaskan cara ${COMPANY.legalName} (“YMB”, “kami”) mengumpulkan, menggunakan, mengungkapkan, menyimpan, dan melindungi data pribadi saat Anda menggunakan situs web, aplikasi seluler, dan layanan terkait YMB (secara bersama-sama disebut “Layanan”).`,
|
||||
sections: [
|
||||
{ title: 'Data yang kami kumpulkan', paragraphs: ['Kami hanya mengumpulkan data yang diperlukan untuk menyediakan, mengamankan, dan meningkatkan Layanan. Jenis data bergantung pada fitur yang Anda gunakan.'], bullets: [
|
||||
'Data akun: nama, alamat email, nomor telepon, kredensial kata sandi jika berlaku, informasi profil, dan identifikasi akun.',
|
||||
'Data Login dengan Google: ID akun Google, nama, alamat email, dan foto profil saat Anda memilih Login dengan Google. Kami tidak menerima kata sandi Google Anda.',
|
||||
'Data penggunaan: riwayat tontonan, progres menonton, pencarian, suka, komentar, mengikuti, interaksi perangkat, dan informasi sesi.',
|
||||
'Data transaksi: ID produk Google Play, token pembelian, status pesanan, perubahan saldo koin, status langganan, dan catatan dukungan. Google memproses metode pembayaran Anda; kami tidak menyimpan nomor kartu lengkap.',
|
||||
'Data perangkat dan diagnostik: model perangkat, sistem operasi, versi aplikasi, bahasa, alamat IP, catatan log, laporan kerusakan, dan perkiraan wilayah berdasarkan IP.',
|
||||
'Konten dan komunikasi: video, komentar, laporan, permintaan, dan materi lain yang Anda kirim, termasuk pesan kepada layanan pelanggan.',
|
||||
] },
|
||||
{ title: 'Cara kami menggunakan data pribadi', bullets: [
|
||||
'Membuat dan mengelola akun serta mengautentikasi proses masuk.',
|
||||
'Menyediakan pemutaran video, lanjut menonton, rekomendasi, pencarian, fitur sosial, dan fungsi kreator.',
|
||||
'Memverifikasi pembelian Google Play, memberikan koin atau langganan, mencegah pemenuhan ganda, menangani pengembalian dana, dan merekonsiliasi pesanan.',
|
||||
'Menjaga keamanan, mencegah penipuan dan penyalahgunaan, memperbaiki kesalahan, mengukur kinerja, serta meningkatkan konten dan fitur.',
|
||||
'Menjawab permintaan dukungan, mengirim pemberitahuan layanan, dan memenuhi kewajiban hukum.',
|
||||
] },
|
||||
{ title: 'Dasar pemrosesan', paragraphs: ['Jika berlaku, kami memproses data pribadi berdasarkan persetujuan Anda, pelaksanaan kontrak dengan Anda, kepentingan sah kami dalam mengoperasikan dan mengamankan Layanan, serta kepatuhan terhadap kewajiban hukum. Anda dapat menarik persetujuan untuk pemrosesan berikutnya, dengan tunduk pada batasan hukum dan kontrak.'] },
|
||||
{ title: 'Kapan kami membagikan data', paragraphs: ['Kami tidak menjual data pribadi Anda. Kami dapat membagikan data terbatas kepada:'], bullets: [
|
||||
'Google untuk Login dengan Google, distribusi Google Play, penagihan, verifikasi pembelian, dan layanan platform terkait.',
|
||||
'Penyedia hosting, penyimpanan cloud, jaringan pengiriman konten, analitik, keamanan, dan dukungan pelanggan yang bertindak atas instruksi kami.',
|
||||
'Penasihat profesional, regulator, pengadilan, atau aparat penegak hukum jika diwajibkan oleh hukum atau diperlukan untuk melindungi hak dan keselamatan.',
|
||||
'Penerus dalam merger, pembiayaan, reorganisasi, atau pengalihan aset, dengan perlindungan kerahasiaan dan data yang sesuai.',
|
||||
] },
|
||||
{ title: 'Pemrosesan internasional', paragraphs: ['Penyedia layanan dapat memproses data di luar Indonesia. Jika kami mentransfer data pribadi secara internasional, kami menggunakan perlindungan yang diwajibkan oleh hukum dan mengambil langkah wajar untuk memastikan tingkat perlindungan yang memadai.'] },
|
||||
{ title: 'Penyimpanan data', paragraphs: ['Kami menyimpan data pribadi hanya selama diperlukan untuk tujuan di atas, termasuk pengoperasian akun, catatan transaksi, penyelesaian sengketa, keamanan, dan kepatuhan hukum. Jangka waktu berbeda menurut jenis data. Jika tidak lagi diperlukan, data akan dihapus, dianonimkan, atau diisolasi secara aman.'] },
|
||||
{ title: 'Keamanan', paragraphs: ['Kami menggunakan perlindungan administratif, teknis, dan organisasi yang wajar, termasuk kontrol akses dan transmisi terenkripsi jika didukung. Tidak ada sistem yang sepenuhnya aman; lindungi kredensial Anda dan beri tahu kami jika ada dugaan akses tanpa izin.'] },
|
||||
{ title: 'Hak Anda', paragraphs: ['Dengan tunduk pada Undang-Undang Republik Indonesia Nomor 27 Tahun 2022 tentang Pelindungan Data Pribadi dan hukum lain yang berlaku, Anda dapat meminta akses, koreksi, penghapusan, pembatasan, penarikan persetujuan, keberatan, atau portabilitas data pribadi. Kami dapat memverifikasi identitas Anda dan menyimpan catatan yang diwajibkan atau diizinkan oleh hukum.'] },
|
||||
{ title: 'Penghapusan akun dan data', paragraphs: [`Untuk meminta penghapusan akun YMB dan data pribadi terkait, kirim email ke ${COMPANY.email} dari email terdaftar atau hubungi kami melalui Layanan. Berikan informasi yang cukup untuk mengidentifikasi akun, tetapi jangan pernah mengirim kata sandi. Kami akan memverifikasi permintaan dan menghapus atau menganonimkan data kecuali penyimpanan diperlukan untuk transaksi, pencegahan penipuan, sengketa, atau kewajiban hukum. Menghapus instalasi aplikasi saja tidak menghapus akun Anda.`] },
|
||||
{ title: 'Anak-anak', paragraphs: ['Layanan tidak ditujukan kepada anak di bawah 13 tahun. Jika hukum setempat menetapkan usia lebih tinggi untuk persetujuan mandiri, persetujuan orang tua atau wali diperlukan. Jika kami mengetahui data anak dikumpulkan tanpa persetujuan yang diwajibkan, kami akan mengambil langkah wajar untuk menghapusnya.'] },
|
||||
{ title: 'Layanan pihak ketiga', paragraphs: ['Google dan layanan pihak ketiga lain memproses data berdasarkan kebijakan privasi mereka. Anda dapat mengelola izin akun Google melalui pengaturan akun Google. Tautan ke situs pihak ketiga tidak berarti kami bertanggung jawab atas praktik mereka.'] },
|
||||
{ title: 'Perubahan kebijakan', paragraphs: ['Kami dapat memperbarui kebijakan ini untuk mencerminkan perubahan Layanan atau persyaratan hukum. Kebijakan revisi akan diterbitkan dengan tanggal berlaku baru dan pemberitahuan tambahan jika diwajibkan. Penggunaan berkelanjutan setelah tanggal berlaku tunduk pada kebijakan revisi.'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
terms: {
|
||||
en: {
|
||||
...labels.en,
|
||||
title: 'Terms of Service',
|
||||
effective: 'Effective date: November 13, 2026',
|
||||
summary: `These Terms of Service govern your access to and use of the YMB Service provided by ${COMPANY.legalName}. By creating an account or using the Service, you agree to these Terms.`,
|
||||
sections: [
|
||||
{ title: 'Eligibility and accounts', paragraphs: ['You must be legally capable of agreeing to these Terms or use the Service with the consent and supervision of a parent or guardian. Provide accurate information, protect your credentials, and notify us of unauthorised activity. You are responsible for activity under your account.'] },
|
||||
{ title: 'The Service', paragraphs: ['YMB provides mobile-first short dramas, related web features, and creator or social functions that may change over time. Content availability can vary by region, language, device, and licensing rights. We may add, remove, or modify features to operate and improve the Service.'] },
|
||||
{ title: 'Google Sign-In and third-party platforms', paragraphs: ['If you choose Google Sign-In, your use of Google is also governed by Google’s terms and privacy policy. The Service is distributed through platforms such as Google Play, whose terms apply to downloads, payments, subscriptions, and refunds.'] },
|
||||
{ title: 'Virtual coins and one-time purchases', paragraphs: ['Virtual coins are limited, non-transferable digital credits used only to access eligible content or features within YMB. They are not money, have no cash value, cannot be redeemed for cash, and must not be traded outside the Service. Prices and applicable taxes are shown before purchase. Except where required by law or approved under the applicable Google Play refund policy, purchases are final and non-refundable.'] },
|
||||
{ title: 'Subscriptions', paragraphs: ['If a subscription is offered, its price, billing period, benefits, and renewal terms are displayed before purchase. Unless cancelled, subscriptions renew through Google Play. You can manage or cancel a subscription in your Google Play account. Cancellation stops future renewals; access continues until the end of the paid period unless otherwise stated.'] },
|
||||
{ title: 'Your content', paragraphs: ['You retain ownership of content you submit. You grant YMB a worldwide, non-exclusive, royalty-free licence to host, store, reproduce, transcode, distribute, publicly perform, display, and promote that content solely to operate and promote the Service. You confirm that you have all necessary rights and that your content does not infringe the rights of others.'] },
|
||||
{ title: 'Acceptable use', paragraphs: ['You must not:'], bullets: [
|
||||
'Upload unlawful, infringing, fraudulent, hateful, sexually exploitative, or harmful content.',
|
||||
'Attempt unauthorised access, interfere with security, scrape at disruptive volume, reverse engineer except where legally permitted, or distribute malware.',
|
||||
'Manipulate purchases, coins, subscriptions, metrics, recommendations, or payment verification.',
|
||||
'Harass others, impersonate a person or entity, or use the Service for misleading commercial spam.',
|
||||
] },
|
||||
{ title: 'Intellectual property', paragraphs: ['The Service, its software, design, branding, and licensed content are owned by YMB or its licensors and protected by intellectual-property laws. Except for the limited right to use the Service under these Terms, no ownership or additional licence is transferred to you.'] },
|
||||
{ title: 'Content enforcement and termination', paragraphs: ['We may remove content, restrict features, suspend, or terminate access if we reasonably believe you violated these Terms, created legal or security risk, or harmed other users or the Service. You may stop using the Service at any time. Provisions that by nature should survive termination remain effective.'] },
|
||||
{ title: 'Disclaimers', paragraphs: ['To the extent permitted by law, the Service is provided on an “as available” basis. We do not guarantee uninterrupted availability, error-free operation, or that every title will remain available. Nothing in these Terms excludes consumer rights that cannot legally be excluded.'] },
|
||||
{ title: 'Limitation of liability', paragraphs: ['To the extent permitted by law, YMB is not liable for indirect, incidental, special, or consequential loss arising from use of the Service. Any statutory rights or liabilities that cannot be limited remain unaffected.'] },
|
||||
{ title: 'Governing law', paragraphs: ['These Terms are governed by the laws of the Republic of Indonesia, without regard to conflict-of-law principles. Courts or dispute-resolution bodies with jurisdiction under applicable Indonesian law will handle disputes, subject to mandatory consumer protections.'] },
|
||||
{ title: 'Changes and contact', paragraphs: ['We may revise these Terms and will publish the updated version with a new effective date. Material changes will receive additional notice where required. Questions may be sent using the contact details below.'] },
|
||||
],
|
||||
},
|
||||
id: {
|
||||
...labels.id,
|
||||
title: 'Ketentuan Layanan',
|
||||
effective: 'Tanggal berlaku: 13 November 2026',
|
||||
summary: `Ketentuan Layanan ini mengatur akses dan penggunaan Layanan YMB yang disediakan oleh ${COMPANY.legalName}. Dengan membuat akun atau menggunakan Layanan, Anda menyetujui Ketentuan ini.`,
|
||||
sections: [
|
||||
{ title: 'Kelayakan dan akun', paragraphs: ['Anda harus secara hukum mampu menyetujui Ketentuan ini atau menggunakan Layanan dengan persetujuan dan pengawasan orang tua atau wali. Berikan informasi yang akurat, lindungi kredensial, dan beri tahu kami tentang aktivitas tanpa izin. Anda bertanggung jawab atas aktivitas dalam akun Anda.'] },
|
||||
{ title: 'Layanan', paragraphs: ['YMB menyediakan drama pendek mobile-first, fitur web terkait, serta fungsi kreator atau sosial yang dapat berubah. Ketersediaan konten dapat berbeda menurut wilayah, bahasa, perangkat, dan hak lisensi. Kami dapat menambah, menghapus, atau mengubah fitur untuk mengoperasikan dan meningkatkan Layanan.'] },
|
||||
{ title: 'Login dengan Google dan platform pihak ketiga', paragraphs: ['Jika Anda memilih Login dengan Google, penggunaan Google juga tunduk pada ketentuan dan kebijakan privasi Google. Layanan didistribusikan melalui platform seperti Google Play, yang ketentuannya berlaku untuk unduhan, pembayaran, langganan, dan pengembalian dana.'] },
|
||||
{ title: 'Koin virtual dan pembelian satu kali', paragraphs: ['Koin virtual adalah kredit digital terbatas dan tidak dapat dipindahtangankan yang hanya digunakan untuk mengakses konten atau fitur yang memenuhi syarat dalam YMB. Koin bukan uang, tidak memiliki nilai tunai, tidak dapat ditukar dengan uang tunai, dan tidak boleh diperdagangkan di luar Layanan. Harga dan pajak yang berlaku ditampilkan sebelum pembelian. Kecuali diwajibkan oleh hukum atau disetujui berdasarkan kebijakan pengembalian dana Google Play yang berlaku, pembelian bersifat final dan tidak dapat dikembalikan.'] },
|
||||
{ title: 'Langganan', paragraphs: ['Jika langganan ditawarkan, harga, periode penagihan, manfaat, dan ketentuan perpanjangan ditampilkan sebelum pembelian. Kecuali dibatalkan, langganan diperpanjang melalui Google Play. Anda dapat mengelola atau membatalkan langganan di akun Google Play. Pembatalan menghentikan perpanjangan berikutnya; akses berlanjut hingga akhir periode yang telah dibayar kecuali dinyatakan lain.'] },
|
||||
{ title: 'Konten Anda', paragraphs: ['Anda tetap memiliki konten yang Anda kirim. Anda memberi YMB lisensi worldwide, non-eksklusif, dan bebas royalti untuk menghosting, menyimpan, memperbanyak, mentranskode, mendistribusikan, mempertunjukkan, menampilkan, dan mempromosikan konten tersebut semata-mata untuk mengoperasikan dan mempromosikan Layanan. Anda menjamin memiliki semua hak yang diperlukan dan konten tidak melanggar hak pihak lain.'] },
|
||||
{ title: 'Penggunaan yang diperbolehkan', paragraphs: ['Anda tidak boleh:'], bullets: [
|
||||
'Mengunggah konten yang melanggar hukum, melanggar hak, menipu, berisi kebencian, eksploitatif secara seksual, atau berbahaya.',
|
||||
'Mencoba akses tanpa izin, mengganggu keamanan, melakukan scraping dalam volume yang mengganggu, merekayasa balik kecuali diizinkan hukum, atau menyebarkan malware.',
|
||||
'Memanipulasi pembelian, koin, langganan, metrik, rekomendasi, atau verifikasi pembayaran.',
|
||||
'Melecehkan orang lain, menyamar sebagai orang atau entitas lain, atau menggunakan Layanan untuk spam komersial yang menyesatkan.',
|
||||
] },
|
||||
{ title: 'Kekayaan intelektual', paragraphs: ['Layanan, perangkat lunak, desain, merek, dan konten berlisensi dimiliki oleh YMB atau pemberi lisensinya dan dilindungi oleh hukum kekayaan intelektual. Kecuali hak terbatas untuk menggunakan Layanan berdasarkan Ketentuan ini, tidak ada kepemilikan atau lisensi tambahan yang dialihkan kepada Anda.'] },
|
||||
{ title: 'Penindakan konten dan penghentian', paragraphs: ['Kami dapat menghapus konten, membatasi fitur, menangguhkan, atau menghentikan akses jika secara wajar kami meyakini Anda melanggar Ketentuan, menimbulkan risiko hukum atau keamanan, atau merugikan pengguna lain maupun Layanan. Anda dapat berhenti menggunakan Layanan kapan saja. Ketentuan yang menurut sifatnya tetap berlaku setelah penghentian akan tetap berlaku.'] },
|
||||
{ title: 'Penafian', paragraphs: ['Sejauh diizinkan oleh hukum, Layanan disediakan berdasarkan ketersediaan. Kami tidak menjamin ketersediaan tanpa gangguan, pengoperasian bebas kesalahan, atau bahwa setiap judul akan selalu tersedia. Tidak ada bagian dalam Ketentuan ini yang mengecualikan hak konsumen yang tidak dapat dikecualikan oleh hukum.'] },
|
||||
{ title: 'Batasan tanggung jawab', paragraphs: ['Sejauh diizinkan oleh hukum, YMB tidak bertanggung jawab atas kerugian tidak langsung, insidental, khusus, atau konsekuensial yang timbul dari penggunaan Layanan. Hak atau tanggung jawab menurut hukum yang tidak dapat dibatasi tetap tidak terpengaruh.'] },
|
||||
{ title: 'Hukum yang berlaku', paragraphs: ['Ketentuan ini diatur oleh hukum Republik Indonesia, tanpa memperhatikan prinsip pertentangan hukum. Pengadilan atau lembaga penyelesaian sengketa yang berwenang berdasarkan hukum Indonesia akan menangani sengketa, dengan tunduk pada perlindungan konsumen yang bersifat wajib.'] },
|
||||
{ title: 'Perubahan dan kontak', paragraphs: ['Kami dapat merevisi Ketentuan ini dan menerbitkan versi terbaru dengan tanggal berlaku baru. Perubahan material akan diberitahukan lebih lanjut jika diwajibkan. Pertanyaan dapat dikirim menggunakan detail kontak di bawah.'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -139,6 +139,7 @@ export function ShortsFeedPage() {
|
||||
onToggleFollow={() => toggleFollow(video.channel)}
|
||||
onOpenComments={() => setCommentsVideo(video)}
|
||||
onShare={() => shareVideo(video)}
|
||||
onEnded={() => move(1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,28 @@ import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
|
||||
import { useLocale } from '../../../app/providers/AppProviders';
|
||||
import { VideoPlayer } from '../../../shared/components/player/VideoPlayer';
|
||||
import { useEngagement } from '../../../shared/hooks/useEngagement';
|
||||
import { useRequest } from '../../../shared/hooks/useRequest';
|
||||
import { formatCount } from '../../../shared/lib/format';
|
||||
import { selectPlaybackUrl } from '../../../shared/lib/media';
|
||||
import { reportWatchProgress } from '../../../shared/lib/watchProgress';
|
||||
import { dramaApi } from '../../../entities/drama/api';
|
||||
|
||||
export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare }) {
|
||||
export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare, onEnded }) {
|
||||
const { locale } = useLocale();
|
||||
const src = video.preview?.playUrl;
|
||||
const previewId = video.preview?.id ? String(video.preview.id) : '';
|
||||
const fallbackSrc = video.preview?.playUrl || '';
|
||||
const playback = useRequest(
|
||||
({ signal }) => dramaApi.playback(previewId, { signal }),
|
||||
[active, previewId],
|
||||
{ enabled: active && Boolean(previewId) },
|
||||
);
|
||||
const src = active
|
||||
? playback.data
|
||||
? selectPlaybackUrl(playback.data, fallbackSrc)
|
||||
: playback.error
|
||||
? fallbackSrc
|
||||
: ''
|
||||
: '';
|
||||
const likeTargetId = video.preview?.id || video.id;
|
||||
const { liked, likeCount, saved, saveCount, toggleLike, toggleSave } = useEngagement({
|
||||
likeTargetType: video.preview?.id ? 'EPISODE' : 'DRAMA',
|
||||
@@ -33,8 +49,8 @@ export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComm
|
||||
<div className="short-player-wrap">
|
||||
<div className="short-slide-player">
|
||||
{src
|
||||
? <VideoPlayer src={src} poster={video.image} autoPlay={active} soundHint={locale.soundHint} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
|
||||
: <div className="short-slide-placeholder"><img src={video.image} alt={video.title} /><span>{video.title}</span></div>}
|
||||
? <VideoPlayer src={src} poster={video.image} autoPlay={active} soundHint={locale.soundHint} onEnded={onEnded} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
|
||||
: <div className="short-slide-placeholder"><img src={video.image} alt={video.title} /><span>{active && playback.loading ? locale.loading : active && playback.error ? locale.playbackUnavailable : video.title}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useLocale, useUi } from '../app/providers/AppProviders';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
||||
|
||||
const FOOTER_LINKS = {
|
||||
id: { privacy: 'Privasi', terms: 'Ketentuan', about: 'Tentang YMB' },
|
||||
en: { privacy: 'Privacy', terms: 'Terms', about: 'About YMB' },
|
||||
zh: { privacy: '隐私政策', terms: '服务条款', about: '关于 YMB' },
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const { locale } = useLocale();
|
||||
const { sidebarOpen, setSidebarOpen } = useUi();
|
||||
const location = useLocation();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const immersive = location.pathname === '/shorts';
|
||||
const links = FOOTER_LINKS[locale.code] || FOOTER_LINKS.en;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,7 +28,18 @@ export function AppShell() {
|
||||
<Suspense fallback={<div className="route-loading" aria-busy="true" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}
|
||||
{!immersive && (
|
||||
<footer className="site-footer">
|
||||
<div>© {new Date().getFullYear()} {locale.copyright}</div>
|
||||
<nav className="site-footer-links" aria-label="Legal and company information">
|
||||
<Link to="/privacy">{links.privacy}</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link to="/terms">{links.terms}</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link to="/about">{links.about}</Link>
|
||||
</nav>
|
||||
</footer>
|
||||
)}
|
||||
</main>
|
||||
{sidebarOpen && (
|
||||
<button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button" />
|
||||
|
||||
@@ -4,5 +4,6 @@ import { App } from './app/App';
|
||||
import './shared/styles/base.css';
|
||||
import './shared/styles/enterprise.css';
|
||||
import './shared/styles/youtube-overrides.css';
|
||||
import './features/legal/legal.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
@@ -11,10 +11,6 @@ export function HeroTransition({ data, onDone }) {
|
||||
if (!data) return undefined;
|
||||
const node = ref.current;
|
||||
if (!node) return undefined;
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
onDone();
|
||||
return undefined;
|
||||
}
|
||||
const rect = data;
|
||||
const scale = Math.max(window.innerWidth / rect.width, window.innerHeight / rect.height) * 1.04;
|
||||
const tx = (window.innerWidth - rect.width * scale) / 2 - rect.left;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
export const COMPANY = {
|
||||
legalName: 'PT YMB TECHNOLOGY INDONESIA',
|
||||
address: 'Komplek Ruko Mega Grosir Cempaka Mas Blok C No. 6, Jakarta Pusat 10640, Indonesia',
|
||||
phone: '+62 821 7000 5974',
|
||||
phoneHref: '+6282170005974',
|
||||
email: 'ymbtechnologyindonesia@gmail.com',
|
||||
nib: '1240000631056',
|
||||
};
|
||||
@@ -7,12 +7,15 @@ export const resolveMedia = (value) => {
|
||||
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
|
||||
};
|
||||
|
||||
export const resolvePlaybackUrl = resolveMedia;
|
||||
export function resolvePlaybackUrl(value) {
|
||||
if (!value) return '';
|
||||
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
|
||||
}
|
||||
|
||||
export const getImage = (item = {}) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
|
||||
|
||||
export function selectPlaybackUrl(playback, fallback = '') {
|
||||
const tracks = playback?.tracks || [];
|
||||
const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0];
|
||||
return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback);
|
||||
return resolvePlaybackUrl(preferred?.playUrl || playback?.playUrl || fallback);
|
||||
}
|
||||
@@ -8,9 +8,20 @@
|
||||
--accent: #ff4b3e;
|
||||
--sidebar: 240px;
|
||||
--topbar: 56px;
|
||||
--focus-ring: 0 0 0 3px #1c62b966;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root { min-width: 0; }
|
||||
|
||||
body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, Helvetica, sans-serif; }
|
||||
body { overflow-x: hidden; background: var(--bg); color: var(--text); font-family: Roboto, Arial, Helvetica, sans-serif; }
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
[role="button"] { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
:where(button, a, input, [role="button"]):focus-visible { outline: 2px solid #8ab4f8; outline-offset: 2px; box-shadow: var(--focus-ring); }
|
||||
|
||||
/* 顶部栏:左中右三段,搜索框居中 */
|
||||
.topbar { position: sticky; top: 0; z-index: 30; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 560px) minmax(0, 1fr); align-items: center; column-gap: 16px; height: var(--topbar); padding: 0 16px; background: var(--bg); border-bottom: 0; box-shadow: none; }
|
||||
@@ -24,9 +35,15 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.search-field { position: relative; display: flex; flex: 1; min-width: 0; }
|
||||
.search-form input { width: 100%; height: 40px; padding: 0 42px 0 16px; border: 1px solid #303030; border-radius: 20px 0 0 20px; background: #121212; color: var(--text); font-size: 14px; }
|
||||
.search-form input::placeholder { color: #888; }
|
||||
.search-form input:focus { outline: none; border-color: #1c62b9; }
|
||||
.search-form input:focus { outline: none; border-color: #1c62b9; box-shadow: inset 0 0 0 1px #1c62b9; }
|
||||
.search-form button { flex: none; display: grid; place-items: center; width: 64px; height: 40px; border: 1px solid #303030; border-left: 0; border-radius: 0 20px 20px 0; background: #222; color: var(--text); cursor: pointer; }
|
||||
.search-form button:hover { background: #303030; }
|
||||
.search-form button:active,
|
||||
.search-clear:active,
|
||||
.create-button:active,
|
||||
.language-trigger:active,
|
||||
.youtube-category-chip:active,
|
||||
.watch-actions button:active { transform: scale(.97); }
|
||||
.search-form .search-field .search-clear { position: absolute; right: 8px; top: 50%; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: #303030; color: #ddd; transform: translateY(-50%); cursor: pointer; }
|
||||
.search-form .search-field .search-clear:hover { background: #444; }
|
||||
.search-empty { display: grid; place-items: center; gap: 8px; padding: 56px 0; color: #aaa; }
|
||||
@@ -36,7 +53,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.create-wrap { position: relative; }
|
||||
.create-button { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; }
|
||||
.create-button:hover { background: #343434; }
|
||||
.create-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 60; min-width: 208px; padding: 8px; border: 1px solid #303030; border-radius: 12px; background: #212121; box-shadow: 0 12px 32px #000a; }
|
||||
.create-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 60; min-width: 208px; padding: 8px; border: 1px solid #303030; border-radius: 12px; background: #212121; box-shadow: 0 12px 32px #000a; transform-origin: top right; animation: ymb-pop-in .18s cubic-bezier(.2, .7, .3, 1) both; }
|
||||
.create-menu button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; }
|
||||
.create-menu button:hover { background: #3a3a3a; }
|
||||
.upload-backdrop { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: #000a; }
|
||||
@@ -72,7 +89,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.profile-chip { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #065fd4; color: #fff; }
|
||||
|
||||
/* 侧边栏:展开 240px,收起 72px 图标 + 文字 */
|
||||
.sidebar { position: fixed; top: var(--topbar); bottom: 0; left: 0; z-index: 20; width: var(--sidebar); padding: 12px; overflow-y: auto; background: var(--bg); border: 0; box-shadow: none; color: var(--text); }
|
||||
.sidebar { position: fixed; top: var(--topbar); bottom: 0; left: 0; z-index: 20; width: var(--sidebar); padding: 12px; overflow-y: auto; background: var(--bg); border: 0; box-shadow: none; color: var(--text); transition: width .24s ease, padding .24s ease, transform .24s ease; }
|
||||
.sidebar::-webkit-scrollbar { width: 8px; }
|
||||
.sidebar::-webkit-scrollbar-thumb { background: #4d4d4d; border-radius: 4px; }
|
||||
.sidebar.closed { width: 72px; padding: 4px 4px 12px; }
|
||||
@@ -87,8 +104,15 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.sidebar.closed .nav-rule { display: block; margin: 8px 12px; }
|
||||
|
||||
/* 内容区 */
|
||||
.content { max-width: none; margin: 0; padding: 12px 24px 80px calc(var(--sidebar) + 24px); }
|
||||
.content { max-width: none; margin: 0; padding: 12px 24px 80px calc(var(--sidebar) + 24px); transition: padding-left .24s ease; }
|
||||
.content-wide { padding-left: calc(72px + 24px); }
|
||||
.content,
|
||||
.content-wide,
|
||||
.youtube-home-feed,
|
||||
.youtube-shorts-shelf,
|
||||
.video-grid,
|
||||
.media-card,
|
||||
.media-copy { min-width: 0; }
|
||||
.site-footer { padding: 28px 0 8px; color: #717171; font-size: 12px; text-align: center; }
|
||||
.youtube-home-feed, .youtube-shorts-shelf { margin-bottom: 40px; }
|
||||
.youtube-category-bar { position: sticky; top: var(--topbar); z-index: 10; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; }
|
||||
@@ -121,7 +145,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
/* 提示与语言菜单 */
|
||||
.api-note { margin: 0 0 16px; padding: 10px 12px; border: 0; border-radius: 8px; background: #181818; color: var(--muted); font-size: 13px; }
|
||||
.api-note.warning { background: #241a1a; color: #e6b8b3; }
|
||||
.language-menu { top: 48px; width: 208px; padding: 8px; border: 1px solid #333; border-radius: 14px; background: #1f1f1f; box-shadow: 0 20px 50px #000a; }
|
||||
.language-menu { top: 48px; width: 208px; padding: 8px; border: 1px solid #333; border-radius: 14px; background: #1f1f1f; box-shadow: 0 20px 50px #000a; transform-origin: top right; animation: ymb-pop-in .18s cubic-bezier(.2, .7, .3, 1) both; }
|
||||
.language-menu-header { padding: 7px 10px 8px; color: #8a8a8a; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.language-menu button { gap: 10px; padding: 10px; border-radius: 9px; color: #ddd; }
|
||||
.language-menu button:hover { background: #2b2b2b; color: #fff; }
|
||||
@@ -377,7 +401,8 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.content, .content-wide { padding: 0 12px calc(24px + env(safe-area-inset-bottom)); }
|
||||
.youtube-category-bar { top: var(--topbar); margin: 0 -12px; padding: 10px 12px; gap: 8px; }
|
||||
.youtube-category-chip { padding: 7px 12px; font-size: 13px; }
|
||||
.video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; }
|
||||
.video-grid { width: 100%; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; }
|
||||
.video-grid > .media-card { width: 100%; min-width: 0; }
|
||||
.media-meta { gap: 8px; padding-top: 9px; }
|
||||
.channel-avatar { width: 28px; height: 28px; font-size: 12px; }
|
||||
.media-title { font-size: 14px; }
|
||||
@@ -386,7 +411,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.youtube-shorts-shelf { margin: 20px -12px 32px; padding: 16px 12px 22px; }
|
||||
.youtube-shelf-heading h2 { font-size: 20px; }
|
||||
.shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; padding-bottom: 2px; }
|
||||
.shorts-row .media-card { flex: 0 0 150px; }
|
||||
.shorts-row .media-card { flex: 0 0 150px; min-width: 150px; }
|
||||
.shorts-row .media-thumb { aspect-ratio: 9 / 16; }
|
||||
.shorts-stage, .shorts-stage-wide { left: 0; bottom: 0; }
|
||||
.short-slide { padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); background: #212121; }
|
||||
@@ -553,6 +578,10 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
@media (prefers-reduced-motion: reduce) { .watch-stage { animation: none; } }
|
||||
|
||||
/* 页面动效:仅使用 transform / opacity,走合成层,不影响布局与滚动性能 */
|
||||
@keyframes ymb-pop-in {
|
||||
from { opacity: 0; transform: translateY(-4px) scale(.96); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes ymb-page-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
@@ -605,11 +634,5 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.content > *, .video-grid .media-card, .shorts-row .media-card,
|
||||
.youtube-home-feed > *, .youtube-shorts-shelf { animation: none; }
|
||||
.media-thumb img, .media-thumb::after, .thumb-play { transition: none; }
|
||||
.media-thumb img, .media-thumb::after, .thumb-play, .sidebar, .content, .create-menu, .language-menu { transition: none; animation: none; }
|
||||
}
|
||||
|
||||
/* 全站文字统一不加粗 */
|
||||
body,
|
||||
body * { font-weight: 400 !important; }
|
||||
b,
|
||||
strong { font-weight: 400 !important; }
|
||||
Reference in new issue
Block a user