- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
82 lines
3.3 KiB
React
82 lines
3.3 KiB
React
// Author: fruit.com.cn
|
|
import { useEffect, useState } from 'react';
|
|
import { Send, X } from 'lucide-react';
|
|
import { engagementApi } from '../api/engagement';
|
|
|
|
function formatTime(value, htmlLang) {
|
|
if (!value) return '';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
return date.toLocaleString(htmlLang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
export function CommentsSheet({ video, locale, onClose, onPosted }) {
|
|
const [comments, setComments] = useState(null);
|
|
const [error, setError] = useState('');
|
|
const [text, setText] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
|
|
const loadComments = () => engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
|
|
.then((payload) => setComments(payload?.list || []))
|
|
.catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); });
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
setComments(null);
|
|
setError('');
|
|
engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
|
|
.then((payload) => active && setComments(payload?.list || []))
|
|
.catch((loadError) => active && setError(loadError.message || 'Request failed'));
|
|
return () => { active = false; };
|
|
}, [video.id, video.preview?.id]);
|
|
|
|
const submit = async (event) => {
|
|
event.preventDefault();
|
|
const content = text.trim();
|
|
if (!content || sending) return;
|
|
setSending(true);
|
|
setError('');
|
|
try {
|
|
await engagementApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
|
|
setText('');
|
|
await loadComments();
|
|
onPosted?.();
|
|
} catch (postError) {
|
|
setError(postError.message || 'Request failed');
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button className="comments-backdrop" onClick={onClose} aria-label="Close comments" type="button" />
|
|
<section className="comments-sheet" role="dialog" aria-label={locale.comments}>
|
|
<header className="comments-header">
|
|
<h2>{locale.comments}</h2>
|
|
<button onClick={onClose} aria-label="Close" type="button"><X size={20} /></button>
|
|
</header>
|
|
<div className="comments-list">
|
|
{comments === null && <p className="comments-note">{locale.loading}</p>}
|
|
{error && <p className="comments-note warning">{error}</p>}
|
|
{comments && !comments.length && !error && <p className="comments-note">{locale.noComments}</p>}
|
|
{comments?.map((comment) => (
|
|
<div className="comment-row" key={comment.id}>
|
|
<span className="comment-avatar">{comment.nickname?.[0] || 'Y'}</span>
|
|
<div className="comment-body">
|
|
<b>{comment.nickname || 'Guest'}</b>
|
|
<p>{comment.content}</p>
|
|
<small>{formatTime(comment.createdAt, locale.htmlLang)}</small>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<form className="comment-form" onSubmit={submit}>
|
|
<input value={text} onChange={(event) => setText(event.target.value)} placeholder={locale.commentPlaceholder} aria-label={locale.commentPlaceholder} />
|
|
<button type="submit" disabled={!text.trim() || sending} aria-label={locale.send}><Send size={18} /></button>
|
|
</form>
|
|
</section>
|
|
</>
|
|
);
|
|
}
|