"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 (
{/* Close button */} {/* Counter */}
{index + 1} / {images.length}
{/* Prev arrow */} {images.length > 1 && ( )} {/* Image */} e.stopPropagation()} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} /> {/* Next arrow */} {images.length > 1 && ( )} {/* Thumbnails */} {images.length > 1 && (
e.stopPropagation()} > {images.map((url, i) => ( ))}
)}
); } export default function TimelinePage() { const { activeClassId } = useActiveClass(); const { user } = useAuth(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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([]); const [previewUrls, setPreviewUrls] = useState([]); const [submitting, setSubmitting] = useState(false); const [uploadProgress, setUploadProgress] = useState(""); const [editingId, setEditingId] = useState(null); const [editingImageUrls, setEditingImageUrls] = useState([]); const fileInputRef = useRef(null); // Lightbox state const [lightboxImages, setLightboxImages] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(0); // Comment state const [expandedComments, setExpandedComments] = useState>(new Set()); const [commentInputs, setCommentInputs] = useState>({}); const [submittingComment, setSubmittingComment] = useState>({}); const openLightbox = (images: string[], index: number) => { setLightboxImages(images); setLightboxIndex(index); }; const closeLightbox = () => setLightboxImages(null); const loadPosts = async () => { setError(null); try { const res = await fetchAPI("/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) => { 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("/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(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(`/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(`/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 (

班级动态

分享动态,交流互动

{ setDialogOpen(open); if (!open) resetForm(); }}> {editingId ? "编辑动态" : "发布动态"}
setNewTitle(e.target.value)} />