From 1f22367cc434227e60562845c37b84a1cfbeae5f Mon Sep 17 00:00:00 2001 From: nyne Date: Fri, 4 Jul 2025 13:23:56 +0800 Subject: [PATCH] Support commenting with markdown. --- frontend/src/app.tsx | 2 + frontend/src/components/comment_input.tsx | 195 ++++++++ frontend/src/components/comment_tile.tsx | 246 ++++++++++ frontend/src/components/resource_card.tsx | 2 +- frontend/src/markdown.css | 146 +++++- frontend/src/network/models.ts | 15 + frontend/src/network/network.ts | 22 +- frontend/src/pages/comment_page.tsx | 140 ++++++ frontend/src/pages/resource_details_page.tsx | 452 +------------------ server/api/comment.go | 50 +- server/dao/comment.go | 46 +- server/model/comment.go | 103 +++-- server/service/comment.go | 175 ++++++- 13 files changed, 1089 insertions(+), 505 deletions(-) create mode 100644 frontend/src/components/comment_input.tsx create mode 100644 frontend/src/components/comment_tile.tsx create mode 100644 frontend/src/pages/comment_page.tsx diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index a82c283..8d48b3d 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -14,6 +14,7 @@ import AboutPage from "./pages/about_page.tsx"; import TagsPage from "./pages/tags_page.tsx"; import RandomPage from "./pages/random_page.tsx"; import ActivitiesPage from "./pages/activities_page.tsx"; +import CommentPage from "./pages/comment_page.tsx"; export default function App() { return ( @@ -34,6 +35,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/comment_input.tsx b/frontend/src/components/comment_input.tsx new file mode 100644 index 0000000..3f0aa54 --- /dev/null +++ b/frontend/src/components/comment_input.tsx @@ -0,0 +1,195 @@ +import { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import showToast from "./toast"; +import { network } from "../network/network"; +import { InfoAlert } from "./alert"; +import { app } from "../app"; +import { MdOutlineImage, MdOutlineInfo } from "react-icons/md"; +import Badge from "./badge"; + +export function CommentInput({ + resourceId, + replyTo, + reload, +}: { + resourceId?: number; + replyTo?: number; + reload: () => void; +}) { + const [commentContent, setCommentContent] = useState(""); + const [isLoading, setLoading] = useState(false); + const [isUploadingimage, setUploadingImage] = useState(false); + const textareaRef = useRef(null); + const { t } = useTranslation(); + + // Auto-resize textarea based on content + const adjustTextareaHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + let height = textareaRef.current.scrollHeight; + if (height < 144) { + height = 144; // Minimum height of 144px (h-36) + } + textareaRef.current.style.height = `${height}px`; + } + }; + + // Reset textarea height to default + const resetTextareaHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = '144px'; // h-36 = 144px + } + }; + + useEffect(() => { + adjustTextareaHeight(); + }, [commentContent]); + + const sendComment = async () => { + if (isLoading) { + return; + } + if (commentContent === "") { + showToast({ + message: t("Comment content cannot be empty"), + type: "error", + }); + return; + } + setLoading(true); + if (resourceId) { + const res = await network.createResourceComment( + resourceId, + commentContent, + ); + if (res.success) { + setCommentContent(""); + resetTextareaHeight(); + showToast({ + message: t("Comment created successfully"), + type: "success", + }); + reload(); + } else { + showToast({ message: res.message, type: "error" }); + } + } else if (replyTo) { + const res = await network.replyToComment(replyTo, commentContent); + if (res.success) { + setCommentContent(""); + resetTextareaHeight(); + showToast({ + message: t("Reply created successfully"), + type: "success", + }); + reload(); + } else { + showToast({ message: res.message, type: "error" }); + } + } else { + showToast({ + message: t("Invalid resource or reply ID"), + type: "error", + }); + } + + setLoading(false); + }; + + const handleAddImage = () => { + if (isUploadingimage) { + return; + } + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.onchange = async (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) { + for (let i = 0; i < files.length; i++) { + if (files[i].size > 8 * 1024 * 1024) { + showToast({ + message: t("Image size exceeds 5MB limit"), + type: "error", + }); + return; + } + } + setUploadingImage(true); + const imageIds: number[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const res = await network.uploadImage(file); + if (res.success) { + imageIds.push(res.data!); + } else { + showToast({ message: res.message, type: "error" }); + setUploadingImage(false); + return; + } + } + if (imageIds.length > 0) { + setCommentContent((prev) => { + return ( + prev + + "\n" + + imageIds.map((id) => `![Image](/api/image/${id})`).join(" ") + ); + }); + } + setUploadingImage(false); + } + }; + input.click(); + }; + + if (!app.isLoggedIn()) { + return ( + + ); + } + + return ( +
+