diff --git a/index.html b/index.html index c04af66..a4fbf30 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ + + + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..288b455 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-192x192.png b/public/favicon-192x192.png new file mode 100644 index 0000000..f4bf2e5 Binary files /dev/null and b/public/favicon-192x192.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..74622df Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/src/app/App.jsx b/src/app/App.jsx index edcef91..d7ce5db 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -7,5 +7,7 @@ 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'; -export function App() { return }>} />} />} />} />} />} />} />; } +export function App() { return }>} />} />} />} />} />} />} />} />; } diff --git a/src/components/CommentsSheet.jsx b/src/components/CommentsSheet.jsx new file mode 100644 index 0000000..d27771b --- /dev/null +++ b/src/components/CommentsSheet.jsx @@ -0,0 +1,81 @@ +// Author: fruit.com.cn +import { useEffect, useState } from 'react'; +import { Send, X } from 'lucide-react'; +import { appApi } from '../services/appApi'; + +function formatTime(value, htmlLang) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString(htmlLang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +export function CommentsSheet({ video, locale, onClose, onPosted }) { + const [comments, setComments] = useState(null); + const [error, setError] = useState(''); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + + const loadComments = () => appApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) + .then((payload) => setComments(payload?.list || [])) + .catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); }); + + useEffect(() => { + let active = true; + setComments(null); + setError(''); + appApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) + .then((payload) => active && setComments(payload?.list || [])) + .catch((loadError) => active && setError(loadError.message || 'Request failed')); + return () => { active = false; }; + }, [video.id, video.preview?.id]); + + const submit = async (event) => { + event.preventDefault(); + const content = text.trim(); + if (!content || sending) return; + setSending(true); + setError(''); + try { + await appApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content }); + setText(''); + await loadComments(); + onPosted?.(); + } catch (postError) { + setError(postError.message || 'Request failed'); + } finally { + setSending(false); + } + }; + + return ( + <> + + +
+ {comments === null &&

{locale.loading}

} + {error &&

{error}

} + {comments && !comments.length && !error &&

{locale.noComments}

} + {comments?.map((comment) => ( +
+ {comment.nickname?.[0] || 'Y'} +
+ {comment.nickname || 'Guest'} +

{comment.content}

+ {formatTime(comment.createdAt, locale.htmlLang)} +
+
+ ))} +
+
+ setText(event.target.value)} placeholder={locale.commentPlaceholder} aria-label={locale.commentPlaceholder} /> + +
+ + + ); +} diff --git a/src/components/LanguageFlag.jsx b/src/components/LanguageFlag.jsx new file mode 100644 index 0000000..a968b00 --- /dev/null +++ b/src/components/LanguageFlag.jsx @@ -0,0 +1,40 @@ +// Author: fruit.com.cn +// 用内联 SVG 绘制国旗,避免 Windows 等系统不渲染 emoji 国旗 +const STAR = 'M0,-1 L0.2245,-0.309 L0.951,-0.309 L0.363,0.118 L0.588,0.809 L0,0.382 L-0.588,0.809 L-0.363,0.118 L-0.951,-0.309 L-0.2245,-0.309 Z'; + +const FLAGS = { + id: ( + + ), + en: ( + + ), + zh: ( + + ), +}; + +export function LanguageFlag({ code }) { + return {FLAGS[code] || FLAGS.id}; +} diff --git a/src/components/LanguageSwitcher.jsx b/src/components/LanguageSwitcher.jsx index 4e8f778..5c9ed9f 100644 --- a/src/components/LanguageSwitcher.jsx +++ b/src/components/LanguageSwitcher.jsx @@ -1,10 +1,43 @@ // Author: fruit.com.cn -import { Check, ChevronDown, Globe2 } from 'lucide-react'; +import { Check, ChevronDown } from 'lucide-react'; import { useState } from 'react'; import { useAppStore } from '../app/store'; +import { LanguageFlag } from './LanguageFlag'; + +const languages = [ + { code: 'id', label: 'Bahasa Indonesia', short: 'ID' }, + { code: 'en', label: 'English', short: 'EN' }, + { code: 'zh', label: '简体中文', short: '中文' }, +]; +const titles = { id: 'Bahasa', en: 'Language', zh: '语言' }; -const languages = [{ code: 'id', label: 'Bahasa Indonesia', short: 'ID', flag: '🇮🇩' }, { code: 'en', label: 'English', short: 'EN', flag: '🇺🇸' }, { code: 'zh', label: '简体中文', short: '中文', flag: '🇨🇳' }]; export function LanguageSwitcher() { - const { lang, setLang } = useAppStore(); const [open, setOpen] = useState(false); const current = languages.find((item) => item.code === lang) || languages[0]; - return
{open && <>)}
}; + const { lang, setLang } = useAppStore(); + const [open, setOpen] = useState(false); + const current = languages.find((item) => item.code === lang) || languages[0]; + const title = titles[lang] || 'Language'; + return ( +
+ + {open && ( + <> + + ))} +
+ + )} + + ); } diff --git a/src/components/MediaCard.jsx b/src/components/MediaCard.jsx index f08238e..2ce0147 100644 --- a/src/components/MediaCard.jsx +++ b/src/components/MediaCard.jsx @@ -1,8 +1,13 @@ // Author: fruit.com.cn import { MoreVertical, Play } from 'lucide-react'; import { Link } from 'react-router-dom'; +import { useAppStore } from '../app/store'; +import { daysAgoLabel, formatViews } from '../lib/format'; -export function MediaCard({ video, compact = false }) { +export function MediaCard({ video, compact = false, to }) { + const { locale } = useAppStore(); const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`; - return
{video.title}{video.duration}{compact ? {video.title} :
{video.channel?.[0] || 'Y'}
{video.title}{video.channel}
{video.views} · 2 hari lalu
}
; + const target = to || `/watch/${video.id}`; + const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · '); + return
{video.title}{video.duration}{compact ? {video.title} :
{video.channel?.[0] || 'Y'}
{video.title}{video.channel}
{meta}
}
; } diff --git a/src/components/SectionHeading.jsx b/src/components/SectionHeading.jsx index 0e0380d..35c0474 100644 --- a/src/components/SectionHeading.jsx +++ b/src/components/SectionHeading.jsx @@ -1,2 +1,2 @@ // Author: fruit.com.cn -export function SectionHeading({ title, action = 'Lihat semua →' }) { return

{title}

; } +export function SectionHeading({ title, action = null }) { return

{title}

{action && }
; } diff --git a/src/components/Skeletons.jsx b/src/components/Skeletons.jsx new file mode 100644 index 0000000..dd03054 --- /dev/null +++ b/src/components/Skeletons.jsx @@ -0,0 +1,104 @@ +// Author: fruit.com.cn +function Skeleton({ className = '' }) { + return