705 lines
27 KiB
TypeScript
705 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useRef, useCallback } from "react";
|
||
import { useActiveClass } from "@/hooks/use-active-class";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI, compressImage } from "@/lib/api";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||
import { ErrorState } from "@/components/error-state";
|
||
import { Pagination } from "@/components/pagination";
|
||
import { toast } from "sonner";
|
||
import type { TimelinePost, TimelineComment } from "@/lib/types";
|
||
|
||
/* ---------- Relative time helper ---------- */
|
||
function relativeTime(dateStr: string): string {
|
||
const now = Date.now();
|
||
const then = new Date(dateStr).getTime();
|
||
const diff = Math.floor((now - then) / 1000);
|
||
if (diff < 60) return "刚刚";
|
||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
|
||
if (diff < 2592000) return `${Math.floor(diff / 86400)} 天前`;
|
||
return new Date(dateStr).toLocaleDateString("zh-CN", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
/* ---------- Image Lightbox ---------- */
|
||
function Lightbox({
|
||
images,
|
||
initialIndex,
|
||
onClose,
|
||
}: {
|
||
images: string[];
|
||
initialIndex: number;
|
||
onClose: () => void;
|
||
}) {
|
||
const [index, setIndex] = useState(initialIndex);
|
||
|
||
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
|
||
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
|
||
|
||
// Keyboard & touch handling
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onClose();
|
||
if (e.key === "ArrowLeft") prev();
|
||
if (e.key === "ArrowRight") next();
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [onClose, prev, next]);
|
||
|
||
// Touch swipe
|
||
const touchStartX = useRef(0);
|
||
const handleTouchStart = (e: React.TouchEvent) => {
|
||
touchStartX.current = e.touches[0].clientX;
|
||
};
|
||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||
const diff = e.changedTouches[0].clientX - touchStartX.current;
|
||
if (Math.abs(diff) > 50) {
|
||
diff > 0 ? prev() : next();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||
onClick={onClose}
|
||
>
|
||
{/* Close button */}
|
||
<button
|
||
className="absolute top-4 right-4 text-white/80 hover:text-white text-3xl z-10"
|
||
onClick={onClose}
|
||
>
|
||
×
|
||
</button>
|
||
|
||
{/* Counter */}
|
||
<div className="absolute top-4 left-4 text-white/70 text-sm">
|
||
{index + 1} / {images.length}
|
||
</div>
|
||
|
||
{/* Prev arrow */}
|
||
{images.length > 1 && (
|
||
<button
|
||
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center text-xl z-10"
|
||
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
|
||
{/* Image */}
|
||
<img
|
||
src={images[index]}
|
||
alt=""
|
||
className="max-w-[90vw] max-h-[85vh] object-contain select-none"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchEnd={handleTouchEnd}
|
||
/>
|
||
|
||
{/* Next arrow */}
|
||
{images.length > 1 && (
|
||
<button
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center text-xl z-10"
|
||
onClick={(e) => { e.stopPropagation(); next(); }}
|
||
>
|
||
›
|
||
</button>
|
||
)}
|
||
|
||
{/* Thumbnails */}
|
||
{images.length > 1 && (
|
||
<div
|
||
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{images.map((url, i) => (
|
||
<button
|
||
key={i}
|
||
className={`w-12 h-12 rounded overflow-hidden border-2 transition-opacity ${
|
||
i === index ? "border-white opacity-100" : "border-transparent opacity-50 hover:opacity-75"
|
||
}`}
|
||
onClick={() => setIndex(i)}
|
||
>
|
||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function TimelinePage() {
|
||
const { activeClassId } = useActiveClass();
|
||
const { user } = useAuth();
|
||
const [posts, setPosts] = useState<TimelinePost[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [newTitle, setNewTitle] = useState("");
|
||
const [newContent, setNewContent] = useState("");
|
||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState("");
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [editingImageUrls, setEditingImageUrls] = useState<string[]>([]);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Lightbox state
|
||
const [lightboxImages, setLightboxImages] = useState<string[] | null>(null);
|
||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||
|
||
// Comment state
|
||
const [expandedComments, setExpandedComments] = useState<Set<number>>(new Set());
|
||
const [commentInputs, setCommentInputs] = useState<Record<number, string>>({});
|
||
const [submittingComment, setSubmittingComment] = useState<Record<number, boolean>>({});
|
||
|
||
const openLightbox = (images: string[], index: number) => {
|
||
setLightboxImages(images);
|
||
setLightboxIndex(index);
|
||
};
|
||
const closeLightbox = () => setLightboxImages(null);
|
||
|
||
const loadPosts = async () => {
|
||
setError(null);
|
||
try {
|
||
const res = await fetchAPI<any>("/api/timeline/", { page_size: "10", page: String(page), class_id: String(activeClassId) });
|
||
setPosts(res.items || []);
|
||
setTotalPages(res.total_pages || 1);
|
||
} catch (err: any) {
|
||
setError(err.message || "加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!activeClassId) return;
|
||
loadPosts();
|
||
}, [activeClassId, page]);
|
||
|
||
/* ---------- File upload helpers ---------- */
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = Array.from(e.target.files || []);
|
||
// Max 9 images
|
||
const toAdd = files.slice(0, 9 - selectedFiles.length - editingImageUrls.length);
|
||
if (toAdd.length === 0) {
|
||
toast.error("最多上传 9 张图片");
|
||
return;
|
||
}
|
||
const newFiles = [...selectedFiles, ...toAdd];
|
||
setSelectedFiles(newFiles);
|
||
// Generate preview URLs
|
||
const newUrls = toAdd.map((f) => URL.createObjectURL(f));
|
||
setPreviewUrls((prev) => [...prev, ...newUrls]);
|
||
};
|
||
|
||
const removeFile = (index: number) => {
|
||
URL.revokeObjectURL(previewUrls[index]);
|
||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||
setPreviewUrls((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
const resetForm = () => {
|
||
setEditingId(null);
|
||
setEditingImageUrls([]);
|
||
setNewTitle("");
|
||
setNewContent("");
|
||
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||
setSelectedFiles([]);
|
||
setPreviewUrls([]);
|
||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
};
|
||
|
||
const openEdit = (post: TimelinePost) => {
|
||
setEditingId(post.id);
|
||
setEditingImageUrls(post.image_urls || []);
|
||
setNewTitle(post.title);
|
||
setNewContent(post.content || "");
|
||
setSelectedFiles([]);
|
||
setPreviewUrls([]);
|
||
setDialogOpen(true);
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
if (!newTitle.trim()) return;
|
||
setSubmitting(true);
|
||
setUploadProgress("准备上传...");
|
||
try {
|
||
if (editingId) {
|
||
// Edit mode: update text + upload new images separately
|
||
await putAPI(`/api/timeline/${editingId}`, {
|
||
title: newTitle,
|
||
content: newContent || null,
|
||
});
|
||
if (selectedFiles.length > 0) {
|
||
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
|
||
const compressed = [];
|
||
for (let i = 0; i < selectedFiles.length; i++) {
|
||
compressed.push(await compressImage(selectedFiles[i]));
|
||
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
|
||
}
|
||
setUploadProgress("上传图片...");
|
||
const formData = new FormData();
|
||
for (const f of compressed) formData.append("files", f);
|
||
await uploadAPI(`/api/timeline/${editingId}/images`, formData);
|
||
}
|
||
toast.success("已更新");
|
||
} else {
|
||
// Create mode: send everything in one FormData request
|
||
const formData = new FormData();
|
||
formData.append("title", newTitle);
|
||
if (newContent) formData.append("content", newContent);
|
||
if (user?.role === "super_admin" && activeClassId) formData.append("class_id", String(activeClassId));
|
||
|
||
if (selectedFiles.length > 0) {
|
||
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
|
||
const compressed = [];
|
||
for (let i = 0; i < selectedFiles.length; i++) {
|
||
compressed.push(await compressImage(selectedFiles[i]));
|
||
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
|
||
}
|
||
setUploadProgress("上传中...");
|
||
for (const f of compressed) formData.append("files", f);
|
||
}
|
||
|
||
await uploadAPI<TimelinePost>("/api/timeline/", formData);
|
||
toast.success("发布成功");
|
||
}
|
||
|
||
resetForm();
|
||
setDialogOpen(false);
|
||
loadPosts();
|
||
} catch (err: any) {
|
||
toast.error(err.message || (editingId ? "更新失败" : "发布失败"));
|
||
} finally {
|
||
setSubmitting(false);
|
||
setUploadProgress("");
|
||
}
|
||
};
|
||
|
||
/* ---------- Delete post ---------- */
|
||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await deleteAPI(`/api/timeline/${id}`);
|
||
toast.success("已删除");
|
||
setDeleteTarget(null);
|
||
loadPosts();
|
||
} catch (err: any) {
|
||
toast.error(err.message || "删除失败");
|
||
}
|
||
};
|
||
|
||
/* ---------- Like ---------- */
|
||
const handleLike = async (postId: number) => {
|
||
try {
|
||
const res = await postAPI<{ liked: boolean; like_count: number }>(`/api/timeline/${postId}/like`);
|
||
setPosts((prev) =>
|
||
prev.map((p) =>
|
||
p.id === postId
|
||
? { ...p, has_liked: res.liked, like_count: res.like_count }
|
||
: p
|
||
)
|
||
);
|
||
} catch (err: any) {
|
||
toast.error(err.message || "操作失败");
|
||
}
|
||
};
|
||
|
||
/* ---------- Comments ---------- */
|
||
const toggleComments = async (postId: number) => {
|
||
setExpandedComments((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(postId)) {
|
||
next.delete(postId);
|
||
return next;
|
||
}
|
||
next.add(postId);
|
||
return next;
|
||
});
|
||
|
||
// Fetch comments when expanding (if not already loaded)
|
||
if (!expandedComments.has(postId)) {
|
||
try {
|
||
const res = await fetchAPI<any>(`/api/timeline/${postId}/comments`);
|
||
const comments = res.items || [];
|
||
setPosts((prev) =>
|
||
prev.map((p) =>
|
||
p.id === postId ? { ...p, comments } : p
|
||
)
|
||
);
|
||
} catch {
|
||
// Silently fail — user can still try to post a new comment
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleAddComment = async (postId: number) => {
|
||
const content = (commentInputs[postId] || "").trim();
|
||
if (!content) return;
|
||
setSubmittingComment((prev) => ({ ...prev, [postId]: true }));
|
||
try {
|
||
const newComment = await postAPI<TimelineComment>(`/api/timeline/${postId}/comments`, { content });
|
||
setPosts((prev) =>
|
||
prev.map((p) => {
|
||
if (p.id !== postId) return p;
|
||
const comments = [...(p.comments || []), newComment];
|
||
return { ...p, comments, comment_count: comments.length };
|
||
})
|
||
);
|
||
setCommentInputs((prev) => ({ ...prev, [postId]: "" }));
|
||
} catch (err: any) {
|
||
toast.error(err.message || "评论失败");
|
||
} finally {
|
||
setSubmittingComment((prev) => ({ ...prev, [postId]: false }));
|
||
}
|
||
};
|
||
|
||
const handleDeleteComment = async (postId: number, commentId: number) => {
|
||
try {
|
||
await deleteAPI(`/api/timeline/comments/${commentId}`);
|
||
setPosts((prev) =>
|
||
prev.map((p) => {
|
||
if (p.id !== postId) return p;
|
||
const comments = (p.comments || []).filter((c) => c.id !== commentId);
|
||
return { ...p, comments, comment_count: comments.length };
|
||
})
|
||
);
|
||
toast.success("已删除评论");
|
||
} catch (err: any) {
|
||
toast.error(err.message || "删除评论失败");
|
||
}
|
||
};
|
||
|
||
/* ---------- Permission helpers ---------- */
|
||
const canEditDelete = (post: TimelinePost): boolean => {
|
||
if (!user) return false;
|
||
if (user.role === "class_admin" || user.role === "super_admin") return true;
|
||
return user.id === post.author_id;
|
||
};
|
||
|
||
const canDeleteComment = (comment: TimelineComment): boolean => {
|
||
if (!user) return false;
|
||
if (user.role === "class_admin" || user.role === "super_admin") return true;
|
||
return user.id === comment.author_id;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold">班级动态</h1>
|
||
<p className="text-gray-500 mt-1">分享动态,交流互动</p>
|
||
</div>
|
||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||
setDialogOpen(open);
|
||
if (!open) resetForm();
|
||
}}>
|
||
<DialogTrigger>
|
||
<Button>发布动态</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{editingId ? "编辑动态" : "发布动态"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 pt-2">
|
||
<Input
|
||
placeholder="标题"
|
||
value={newTitle}
|
||
onChange={(e) => setNewTitle(e.target.value)}
|
||
/>
|
||
<Textarea
|
||
placeholder="内容描述..."
|
||
value={newContent}
|
||
onChange={(e) => setNewContent(e.target.value)}
|
||
rows={4}
|
||
/>
|
||
{/* Image upload */}
|
||
<div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
<div className="flex flex-wrap gap-2">
|
||
{/* Existing images (edit mode) */}
|
||
{editingImageUrls.map((url, idx) => (
|
||
<div key={`existing-${idx}`} className="relative w-20 h-20 rounded-lg overflow-hidden border">
|
||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
))}
|
||
{/* New image previews */}
|
||
{previewUrls.map((url, idx) => (
|
||
<div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border">
|
||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
className="absolute top-0 right-0 w-5 h-5 bg-black/60 text-white text-xs flex items-center justify-center rounded-bl"
|
||
onClick={() => removeFile(idx)}
|
||
>
|
||
x
|
||
</button>
|
||
</div>
|
||
))}
|
||
{(selectedFiles.length + editingImageUrls.length) < 9 && (
|
||
<button
|
||
type="button"
|
||
className="w-20 h-20 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-400 hover:border-gray-400 hover:text-gray-500 transition-colors"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<span className="text-2xl">+</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-1">最多 9 张,支持 JPG/PNG/GIF/WebP</p>
|
||
</div>
|
||
<Button onClick={handleCreate} disabled={submitting} className="w-full">
|
||
{submitting ? (uploadProgress || (editingId ? "保存中..." : "发布中...")) : (editingId ? "保存" : "发布")}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="space-y-4">
|
||
{[1, 2, 3].map((i) => (
|
||
<Card key={i} className="animate-pulse">
|
||
<CardContent className="p-6">
|
||
<div className="h-40 bg-gray-200 rounded" />
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : error ? (
|
||
<ErrorState message={error} onRetry={loadPosts} />
|
||
) : posts.length === 0 ? (
|
||
<div className="text-center py-12 text-gray-400">暂无动态,快来发布第一条吧</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{posts.map((post) => (
|
||
<Card key={post.id}>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-semibold">{post.title}</h3>
|
||
<p className="text-sm text-gray-500 mt-1">
|
||
{post.author_name} ·{" "}
|
||
{new Date(post.created_at).toLocaleDateString("zh-CN", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
})}
|
||
</p>
|
||
</div>
|
||
{canEditDelete(post) && (
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => openEdit(post)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-red-500 hover:text-red-700"
|
||
onClick={() => setDeleteTarget(post.id)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{post.content && (
|
||
<p className="mt-4 text-gray-700 whitespace-pre-wrap">
|
||
{post.content}
|
||
</p>
|
||
)}
|
||
{post.image_urls && post.image_urls.length > 0 && (
|
||
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 gap-2">
|
||
{post.image_urls.map((url, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="aspect-video bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||
onClick={() => openLightbox(post.image_urls!, idx)}
|
||
>
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Action bar */}
|
||
<div className="mt-4 pt-3 border-t flex items-center gap-6 text-sm">
|
||
{/* Like button */}
|
||
<button
|
||
className="flex items-center gap-1.5 transition-colors hover:text-red-500"
|
||
onClick={() => handleLike(post.id)}
|
||
>
|
||
{post.has_liked ? (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 24 24"
|
||
fill="currentColor"
|
||
className="w-5 h-5 text-red-500"
|
||
>
|
||
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
||
</svg>
|
||
) : (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
strokeWidth={1.5}
|
||
stroke="currentColor"
|
||
className="w-5 h-5"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||
</svg>
|
||
)}
|
||
<span className={post.has_liked ? "text-red-500 font-medium" : "text-gray-500"}>
|
||
{post.like_count > 0 ? post.like_count : "赞"}
|
||
</span>
|
||
</button>
|
||
|
||
{/* Comment button */}
|
||
<button
|
||
className="flex items-center gap-1.5 transition-colors hover:text-blue-500"
|
||
onClick={() => toggleComments(post.id)}
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
strokeWidth={1.5}
|
||
stroke="currentColor"
|
||
className="w-5 h-5"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" />
|
||
</svg>
|
||
<span className="text-gray-500">
|
||
{post.comment_count > 0 ? post.comment_count : "评论"}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Comment section (expandable) */}
|
||
{expandedComments.has(post.id) && (
|
||
<div className="mt-3 pt-3 border-t">
|
||
{/* Existing comments */}
|
||
{post.comments && post.comments.length > 0 && (
|
||
<div className="space-y-3 mb-3">
|
||
{post.comments.map((comment) => (
|
||
<div key={comment.id} className="flex items-start gap-2 group">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-gray-800">{comment.author_name}</span>
|
||
<span className="text-xs text-gray-400">{relativeTime(comment.created_at)}</span>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-0.5">{comment.content}</p>
|
||
</div>
|
||
{canDeleteComment(comment) && (
|
||
<button
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 p-1 shrink-0"
|
||
title="删除评论"
|
||
onClick={() => handleDeleteComment(post.id, comment.id)}
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* New comment input */}
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="写评论..."
|
||
value={commentInputs[post.id] || ""}
|
||
onChange={(e) =>
|
||
setCommentInputs((prev) => ({ ...prev, [post.id]: e.target.value }))
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleAddComment(post.id);
|
||
}
|
||
}}
|
||
disabled={submittingComment[post.id]}
|
||
className="flex-1"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handleAddComment(post.id)}
|
||
disabled={submittingComment[post.id] || !(commentInputs[post.id] || "").trim()}
|
||
>
|
||
发送
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||
|
||
{/* Delete confirmation */}
|
||
<ConfirmDialog
|
||
open={deleteTarget !== null}
|
||
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
||
title="删除动态"
|
||
description="确定删除这条动态?此操作不可恢复。"
|
||
confirmText="删除"
|
||
variant="destructive"
|
||
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
|
||
/>
|
||
|
||
{/* Lightbox */}
|
||
{lightboxImages && (
|
||
<Lightbox
|
||
images={lightboxImages}
|
||
initialIndex={lightboxIndex}
|
||
onClose={closeLightbox}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|