hku-class/frontend/src/app/(app)/timeline/page.tsx
2026-04-20 21:18:05 +08:00

705 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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}
>
&times;
</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(); }}
>
&#8249;
</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(); }}
>
&#8250;
</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>
);
}