"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 (
{loading ? (
{[1, 2, 3].map((i) => (
))}
) : error ? (
) : posts.length === 0 ? (
暂无动态,快来发布第一条吧
) : (
{posts.map((post) => (
{post.title}
{post.author_name} ·{" "}
{new Date(post.created_at).toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
{canEditDelete(post) && (
)}
{post.content && (
{post.content}
)}
{post.image_urls && post.image_urls.length > 0 && (
{post.image_urls.map((url, idx) => (
openLightbox(post.image_urls!, idx)}
>
))}
)}
{/* Action bar */}
{/* Like button */}
{/* Comment button */}
{/* Comment section (expandable) */}
{expandedComments.has(post.id) && (
{/* Existing comments */}
{post.comments && post.comments.length > 0 && (
{post.comments.map((comment) => (
{comment.author_name}
{relativeTime(comment.created_at)}
{comment.content}
{canDeleteComment(comment) && (
)}
))}
)}
{/* New comment input */}
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"
/>
)}
))}
)}
{/* Delete confirmation */}
{ if (!open) setDeleteTarget(null); }}
title="删除动态"
description="确定删除这条动态?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
{/* Lightbox */}
{lightboxImages && (
)}
);
}