Support commenting with markdown.

This commit is contained in:
nyne
2025-07-04 13:23:56 +08:00
parent f605485f40
commit 1f22367cc4
13 changed files with 1089 additions and 505 deletions

View File

@@ -14,6 +14,7 @@ import AboutPage from "./pages/about_page.tsx";
import TagsPage from "./pages/tags_page.tsx"; import TagsPage from "./pages/tags_page.tsx";
import RandomPage from "./pages/random_page.tsx"; import RandomPage from "./pages/random_page.tsx";
import ActivitiesPage from "./pages/activities_page.tsx"; import ActivitiesPage from "./pages/activities_page.tsx";
import CommentPage from "./pages/comment_page.tsx";
export default function App() { export default function App() {
return ( return (
@@ -34,6 +35,7 @@ export default function App() {
<Route path={"/tags"} element={<TagsPage />} /> <Route path={"/tags"} element={<TagsPage />} />
<Route path={"/random"} element={<RandomPage />} /> <Route path={"/random"} element={<RandomPage />} />
<Route path={"/activity"} element={<ActivitiesPage />} /> <Route path={"/activity"} element={<ActivitiesPage />} />
<Route path={"/comments/:id"} element={<CommentPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -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<HTMLTextAreaElement>(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 (
<InfoAlert
message={t("You need to log in to comment")}
className={"my-4 alert-info"}
/>
);
}
return (
<div className={"mt-4 mb-6 textarea w-full p-4 flex flex-col"}>
<textarea
ref={textareaRef}
placeholder={t("Write down your comment")}
className={"w-full resize-none grow h-36"}
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
/>
<div className={"flex items-center"}>
<button
className={"btn btn-ghost btn-sm btn-circle"}
onClick={handleAddImage}
>
{isUploadingimage ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : (
<MdOutlineImage size={18} />
)}
</button>
<Badge className="badge-ghost">
<MdOutlineInfo size={18} />
<span>
{t("Use markdown format.")}
</span>
</Badge>
<span className={"grow"} />
<button
onClick={sendComment}
className={`btn btn-primary h-8 text-sm mx-2 ${commentContent === "" && "btn-disabled"}`}
>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Submit")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { MdOutlineDelete, MdOutlineEdit, MdOutlineReply } from "react-icons/md";
import { TextArea } from "./input";
import { Comment } from "../network/models";
import { network } from "../network/network";
import Badge from "./badge";
import { app } from "../app";
import showToast from "./toast";
import Markdown from "react-markdown";
export function CommentTile({
comment,
onUpdated,
}: {
comment: Comment;
onUpdated?: () => void;
}) {
const navigate = useNavigate();
const { t } = useTranslation();
const link = `/comments/${comment.id}`;
// @ts-ignore
return (
<a
href={link}
className={
"block card bg-base-100 p-2 my-3 shadow-xs hover:shadow transition-shadow cursor-pointer"
}
onClick={(e) => {
e.preventDefault();
navigate(link);
}}
>
<div className={"flex flex-row items-center my-1 mx-1"}>
<div
className="avatar cursor-pointer"
onClick={() =>
navigate(`/user/${encodeURIComponent(comment.user.username)}`)
}
>
<div className="w-8 rounded-full">
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
</div>
</div>
<div className={"w-2"}></div>
<div
className={"text-sm font-bold cursor-pointer"}
onClick={() => {
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
}}
>
{comment.user.username}
</div>
<div className={"grow"}></div>
{comment.reply_count > 0 && (
<Badge className={"badge-soft badge-info badge-sm mr-2"}>
<MdOutlineReply size={16} className={"inline-block"} />
<span className={"w-1"} />
{comment.reply_count}
</Badge>
)}
<Badge className={"badge-soft badge-primary badge-sm"}>
{new Date(comment.created_at).toLocaleString()}
</Badge>
</div>
<div className={"p-2 comment_tile"}>
<CommentContent content={comment.content} />
</div>
<div className={"flex"}>
{comment.content_truncated && (
<Badge className="badge-soft">{t("Click to view more")}</Badge>
)}
<span className={"grow"}></span>
{app.user?.id === comment.user.id && (
<>
<EditCommentDialog comment={comment} onUpdated={onUpdated} />
<DeleteCommentDialog commentId={comment.id} onUpdated={onUpdated} />
</>
)}
</div>
</a>
);
}
function EditCommentDialog({
comment,
onUpdated,
}: {
comment: Comment;
onUpdated?: () => void;
}) {
const [isLoading, setLoading] = useState(false);
const [content, setContent] = useState(comment.content);
const { t } = useTranslation();
const handleUpdate = async () => {
if (isLoading) {
return;
}
setLoading(true);
const res = await network.updateComment(comment.id, content);
const dialog = document.getElementById(
`edit_comment_dialog_${comment.id}`,
) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({
message: t("Comment updated successfully"),
type: "success",
});
if (onUpdated) {
onUpdated();
}
} else {
showToast({
message: res.message,
type: "error",
parent: document.getElementById(`dialog_box`),
});
}
setLoading(false);
};
return (
<>
<button
className={"btn btn-sm btn-ghost ml-1"}
onClick={() => {
const dialog = document.getElementById(
`edit_comment_dialog_${comment.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineEdit size={16} className={"inline-block"} />
{t("Edit")}
</button>
<dialog id={`edit_comment_dialog_${comment.id}`} className="modal">
<div className="modal-box" id={"dialog_box"}>
<h3 className="font-bold text-lg">{t("Edit Comment")}</h3>
<TextArea
label={t("Content")}
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className="btn btn-primary" onClick={handleUpdate}>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Submit")}
</button>
</div>
</div>
</dialog>
</>
);
}
function DeleteCommentDialog({
commentId,
onUpdated,
}: {
commentId: number;
onUpdated?: () => void;
}) {
const [isLoading, setLoading] = useState(false);
const { t } = useTranslation();
const id = `delete_comment_dialog_${commentId}`;
const handleDelete = async () => {
if (isLoading) return;
setLoading(true);
const res = await network.deleteComment(commentId);
const dialog = document.getElementById(id) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({
message: t("Comment deleted successfully"),
type: "success",
});
if (onUpdated) {
onUpdated();
}
} else {
showToast({ message: res.message, type: "error" });
}
setLoading(false);
};
return (
<>
<button
className={"btn btn-error btn-sm btn-ghost ml-1"}
onClick={() => {
const dialog = document.getElementById(id) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineDelete size={16} className={"inline-block"} />
{t("Delete")}
</button>
<dialog id={id} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete Comment")}</h3>
<p className="py-4">
{t(
"Are you sure you want to delete this comment? This action cannot be undone.",
)}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className="btn btn-error" onClick={handleDelete}>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Delete")}
</button>
</div>
</div>
</dialog>
</>
);
}
function CommentContent({ content }: { content: string }) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (!line.endsWith(" ")) {
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
lines[i] = line + " ";
}
}
content = lines.join("\n");
return <Markdown>{content}</Markdown>;
}

View File

@@ -34,7 +34,7 @@ export default function ResourceCard({ resource }: { resource: Resource }) {
</figure> </figure>
)} )}
<div className="flex flex-col p-4"> <div className="flex flex-col p-4">
<h2 className="card-title">{resource.title}</h2> <h2 className="card-title break-all">{resource.title}</h2>
<div className="h-2"></div> <div className="h-2"></div>
<p> <p>
{tags.map((tag) => { {tags.map((tag) => {

View File

@@ -1,6 +1,7 @@
article { article {
& { & {
color: var(--color-base-content); color: var(--color-base-content);
word-break: break-all;
} }
h1 { h1 {
@@ -9,59 +10,70 @@ article {
padding: 12px 0; padding: 12px 0;
margin: 24px 0 12px; margin: 24px 0 12px;
} }
h2 { h2 {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
padding: 12px 0; padding: 12px 0;
margin: 16px 0 8px; margin: 16px 0 8px;
} }
h3 { h3 {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
padding: 6px 0; padding: 6px 0;
margin: 12px 0 4px; margin: 12px 0 4px;
} }
h4 { h4 {
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
padding: 6px 0; padding: 6px 0;
margin: 12px 0 4px; margin: 12px 0 4px;
} }
h5 { h5 {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
padding: 4px 0; padding: 4px 0;
} }
h6 { h6 {
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
padding: 2px 0; padding: 2px 0;
} }
p { p {
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
margin: 12px 0; margin: 12px 0;
} }
ul { ul {
list-style-type: disc; list-style-type: disc;
margin: 0 0 16px 20px; margin: 0 0 16px 20px;
padding: 0; padding: 0;
li { li {
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
margin: 0 0 8px; margin: 0 0 8px;
} }
} }
ol { ol {
list-style-type: decimal; list-style-type: decimal;
margin: 0 0 16px 20px; margin: 0 0 16px 20px;
padding: 0; padding: 0;
li { li {
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
margin: 0 0 8px; margin: 0 0 8px;
} }
} }
blockquote { blockquote {
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
@@ -70,25 +82,31 @@ article {
border-left: 4px solid var(--color-base-300); border-left: 4px solid var(--color-base-300);
background-color: var(--color-base-200); background-color: var(--color-base-200);
} }
hr { hr {
border: 0; border: 0;
border-top: 1px solid var(--color-base-300); border-top: 1px solid var(--color-base-300);
margin: 16px 0; margin: 16px 0;
} }
a { a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
img { img {
border-radius: 8px; border-radius: 8px;
max-height: 400px; max-height: 400px;
} }
p:has(> img) { p:has(> img) {
margin: 16px 0; margin: 16px 0;
} }
p code { p code {
background-color: var(--color-base-200); background-color: var(--color-base-200);
padding: 2px 4px; padding: 2px 4px;
@@ -96,14 +114,140 @@ article {
font-family: font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui; ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
} }
iframe { iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
.comment_tile {
& {
color: var(--color-base-content);
word-break: break-all;
}
h1 {
font-size: 20px;
font-weight: bold;
padding: 8px 0;
margin: 16px 0 8px;
}
h2 {
font-size: 18px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 6px;
}
h3 {
font-size: 15px;
font-weight: bold;
padding: 4px 0;
margin: 8px 0 4px;
}
h4 {
font-size: 13px;
font-weight: bold;
padding: 3px 0;
margin: 8px 0 4px;
}
h5 {
font-size: 11px;
font-weight: bold;
padding: 2px 0;
}
h6 {
font-size: 10px;
font-weight: bold;
padding: 1px 0;
}
p {
font-size: 13px;
line-height: 1.4;
margin: 8px 0;
}
ul {
list-style-type: disc;
margin: 0 0 12px 16px;
padding: 0;
li {
font-size: 13px;
line-height: 1.4;
margin: 0 0 4px;
}
}
ol {
list-style-type: decimal;
margin: 0 0 12px 16px;
padding: 0;
li {
font-size: 13px;
line-height: 1.4;
margin: 0 0 4px;
}
}
blockquote {
font-size: 13px;
line-height: 1.4;
margin: 0 0 12px;
padding: 6px;
border-left: 3px solid var(--color-base-300);
background-color: var(--color-base-200);
}
hr {
border: 0;
border-top: 1px solid var(--color-base-300);
margin: 12px 0;
}
a {
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
img {
border-radius: 6px;
max-height: 180px;
margin: 6px 6px 6px 0;
}
p>img {
display: inline-block;
}
p:has(> img) {
margin: 12px 0;
}
p code {
background-color: var(--color-base-200);
padding: 1px 3px;
border-radius: 3px;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
font-size: 12px;
}
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: none; text-decoration: none;
} }

View File

@@ -120,6 +120,8 @@ export interface Comment {
created_at: string; created_at: string;
user: User; user: User;
images: Image[]; images: Image[];
content_truncated: boolean;
reply_count: number;
} }
export interface CommentWithResource { export interface CommentWithResource {
@@ -129,6 +131,19 @@ export interface CommentWithResource {
user: User; user: User;
images: Image[]; images: Image[];
resource: Resource; resource: Resource;
content_truncated: boolean;
reply_count: number;
}
export interface CommentWithRef {
id: number;
content: string;
created_at: string;
user: User;
images: Image[];
reply_count: number;
resource?: Resource;
reply_to?: Comment;
} }
export interface ServerConfig { export interface ServerConfig {

View File

@@ -18,6 +18,7 @@ import {
RSort, RSort,
TagWithCount, TagWithCount,
Activity, Activity,
CommentWithRef,
} from "./models.ts"; } from "./models.ts";
class Network { class Network {
@@ -574,12 +575,10 @@ class Network {
async createResourceComment( async createResourceComment(
resourceID: number, resourceID: number,
content: string, content: string,
images: number[],
): Promise<Response<any>> { ): Promise<Response<any>> {
return this._callApi(() => return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/comments/resource/${resourceID}`, { axios.post(`${this.apiBaseUrl}/comments/resource/${resourceID}`, {
content, content,
images,
}), }),
); );
} }
@@ -587,12 +586,21 @@ class Network {
async updateComment( async updateComment(
commentID: number, commentID: number,
content: string, content: string,
images: number[],
): Promise<Response<any>> { ): Promise<Response<any>> {
return this._callApi(() => return this._callApi(() =>
axios.put(`${this.apiBaseUrl}/comments/${commentID}`, { axios.put(`${this.apiBaseUrl}/comments/${commentID}`, {
content, content,
images, }),
);
}
async replyToComment(
commentID: number,
content: string,
): Promise<Response<any>> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/comments/reply/${commentID}`, {
content,
}), }),
); );
} }
@@ -622,6 +630,12 @@ class Network {
); );
} }
async getComment(commentID: number): Promise<Response<CommentWithRef>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/comments/${commentID}`),
);
}
async deleteComment(commentID: number): Promise<Response<void>> { async deleteComment(commentID: number): Promise<Response<void>> {
return this._callApi(() => return this._callApi(() =>
axios.delete(`${this.apiBaseUrl}/comments/${commentID}`), axios.delete(`${this.apiBaseUrl}/comments/${commentID}`),

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { network } from "../network/network";
import showToast from "../components/toast";
import { useNavigate, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { CommentWithRef, Resource } from "../network/models";
import Loading from "../components/loading";
import Markdown from "react-markdown";
import Badge from "../components/badge";
export default function CommentPage() {
const params = useParams();
const commentId = params.id;
const [comment, setComment] = useState<CommentWithRef | null>(null);
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
const id = parseInt(commentId || "0");
if (isNaN(id) || id <= 0) {
showToast({
message: t("Invalid comment ID"),
type: "error",
});
return;
}
network.getComment(id).then((res) => {
if (res.success) {
setComment(res.data!);
} else {
showToast({
message: res.message,
type: "error",
});
}
});
}, []);
useEffect(() => {
document.title = t("Comment Details");
});
if (!comment) {
return <Loading />;
}
return (
<div className="p-4">
<h1 className="text-2xl font-bold my-2">{t("Comment")}</h1>
<button
onClick={() => {
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
}}
className="border-b-2 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{comment.user.username}</div>
</div>
</button>
{comment.resource && <ResourceCard resource={comment.resource} />}
<div className="flex"></div>
<article>
<CommentContent content={comment.content} />
</article>
</div>
);
}
function CommentContent({ content }: { content: string }) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (!line.endsWith(" ")) {
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
lines[i] = line + " ";
}
}
content = lines.join("\n");
return <Markdown>{content}</Markdown>;
}
function ResourceCard({ resource }: { resource: Resource }) {
const navigate = useNavigate();
let tags = resource.tags;
if (tags.length > 10) {
tags = tags.slice(0, 10);
}
const link = `/resources/${resource.id}`;
return (
<a
href="link"
className="flex flex-row w-full card bg-base-200 shadow overflow-clip my-2"
onClick={(e) => {
e.preventDefault();
navigate(link);
}}
>
{resource.image != null && (
<img
className="object-cover w-32 sm:w-40 md:w-44 lg:w-52 max-h-64"
src={network.getResampledImageUrl(resource.image.id)}
alt="cover"
/>
)}
<div className="flex flex-col p-4 flex-1">
<h2 className="card-title w-full break-all">{resource.title}</h2>
<div className="h-2"></div>
<p>
{tags.map((tag) => {
return (
<Badge key={tag.id} className={"m-0.5"}>
{tag.name}
</Badge>
);
})}
</p>
<div className="flex-1"></div>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} />
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
</div>
</a>
);
}

View File

@@ -24,22 +24,18 @@ import "../markdown.css";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import { import {
MdAdd, MdAdd,
MdArrowDownward,
MdArrowUpward,
MdClose,
MdOutlineArticle, MdOutlineArticle,
MdOutlineComment, MdOutlineComment,
MdOutlineDataset, MdOutlineDataset,
MdOutlineDelete, MdOutlineDelete,
MdOutlineDownload, MdOutlineDownload,
MdOutlineEdit, MdOutlineEdit,
MdOutlineImage,
MdOutlineLink, MdOutlineLink,
MdOutlineOpenInNew, MdOutlineOpenInNew,
} from "react-icons/md"; } from "react-icons/md";
import { app } from "../app.ts"; import { app } from "../app.ts";
import { uploadingManager } from "../network/uploading.ts"; import { uploadingManager } from "../network/uploading.ts";
import { ErrorAlert, InfoAlert } from "../components/alert.tsx"; import { ErrorAlert } from "../components/alert.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Pagination from "../components/pagination.tsx"; import Pagination from "../components/pagination.tsx";
import showPopup, { useClosePopup } from "../components/popup.tsx"; import showPopup, { useClosePopup } from "../components/popup.tsx";
@@ -48,8 +44,9 @@ import Button from "../components/button.tsx";
import Badge, { BadgeAccent } from "../components/badge.tsx"; import Badge, { BadgeAccent } from "../components/badge.tsx";
import Input, { TextArea } from "../components/input.tsx"; import Input, { TextArea } from "../components/input.tsx";
import { useAppContext } from "../components/AppContext.tsx"; import { useAppContext } from "../components/AppContext.tsx";
import { ImageGrid, SquareImage } from "../components/image.tsx";
import { BiLogoSteam } from "react-icons/bi"; import { BiLogoSteam } from "react-icons/bi";
import { CommentTile } from "../components/comment_tile.tsx";
import { CommentInput } from "../components/comment_input.tsx";
export default function ResourcePage() { export default function ResourcePage() {
const params = useParams(); const params = useParams();
@@ -1193,147 +1190,6 @@ function Comments({ resourceId }: { resourceId: number }) {
); );
} }
function CommentInput({
resourceId,
reload,
}: {
resourceId: number;
reload: () => void;
}) {
const [commentContent, setCommentContent] = useState("");
const [isLoading, setLoading] = useState(false);
const [images, setImages] = useState<File[]>([]);
const { t } = useTranslation();
const sendComment = async () => {
if (isLoading) {
return;
}
if (commentContent === "") {
showToast({
message: t("Comment content cannot be empty"),
type: "error",
});
return;
}
setLoading(true);
const imageIds: number[] = [];
for (const image of images) {
const res = await network.uploadImage(image);
if (res.success) {
imageIds.push(res.data!);
} else {
showToast({ message: res.message, type: "error" });
setLoading(false);
return;
}
}
const res = await network.createResourceComment(
resourceId,
commentContent,
imageIds,
);
if (res.success) {
setCommentContent("");
setImages([]);
showToast({
message: t("Comment created successfully"),
type: "success",
});
reload();
} else {
showToast({ message: res.message, type: "error" });
}
setLoading(false);
};
const handleAddImage = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if ((files?.length ?? 0) + images.length > 9) {
showToast({
message: t("You can only upload up to 9 images"),
type: "error",
});
return;
}
if (files) {
setImages((prev) => [...prev, ...Array.from(files)]);
}
};
input.click();
};
if (!app.isLoggedIn()) {
return (
<InfoAlert
message={t("You need to log in to comment")}
className={"my-4 alert-info"}
/>
);
}
return (
<div className={"mt-4 mb-6 textarea w-full p-4 flex flex-col"}>
<textarea
placeholder={t("Write down your comment")}
className={"w-full resize-none grow h-40"}
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
/>
{images.length > 0 && (
<div
className={
"grid grid-cols-3 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 gap-2 my-2"
}
>
{images.map((image, index) => (
<div key={index} className={"relative"}>
<img
src={URL.createObjectURL(image)}
alt={`comment-image-${index}`}
className={"rounded-lg aspect-square object-cover"}
/>
<button
className={
"btn btn-xs btn-circle btn-error absolute top-1 right-1"
}
onClick={() => {
setImages((prev) => prev.filter((_, i) => i !== index));
}}
>
<MdClose size={14} />
</button>
</div>
))}
</div>
)}
<div className={"flex"}>
<button
className={"btn btn-ghost btn-sm btn-circle"}
onClick={handleAddImage}
>
<MdOutlineImage size={18} />
</button>
<span className={"grow"} />
<button
onClick={sendComment}
className={`btn btn-primary h-8 text-sm mx-2 ${commentContent === "" && "btn-disabled"}`}
>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Submit")}
</button>
</div>
</div>
);
}
function CommentsList({ function CommentsList({
resourceId, resourceId,
page, page,
@@ -1345,6 +1201,8 @@ function CommentsList({
}) { }) {
const [comments, setComments] = useState<Comment[] | null>(null); const [comments, setComments] = useState<Comment[] | null>(null);
const reload = useContext(context);
useEffect(() => { useEffect(() => {
network.listResourceComments(resourceId, page).then((res) => { network.listResourceComments(resourceId, page).then((res) => {
if (res.success) { if (res.success) {
@@ -1370,77 +1228,14 @@ function CommentsList({
return ( return (
<> <>
{comments.map((comment) => { {comments.map((comment) => {
return <CommentTile comment={comment} key={comment.id} />; return (
<CommentTile comment={comment} key={comment.id} onUpdated={reload} />
);
})} })}
</> </>
); );
} }
function CommentTile({ comment }: { comment: Comment }) {
const navigate = useNavigate();
const [expanded, setExpanded] = useState(false);
const { t } = useTranslation();
const isLongComment = comment.content.length > 300;
const displayContent =
expanded || !isLongComment
? comment.content
: comment.content.substring(0, 300) + "...";
// @ts-ignore
return (
<div className={"card bg-base-100 p-2 my-3 shadow-xs"}>
<div className={"flex flex-row items-center my-1 mx-1"}>
<div
className="avatar cursor-pointer"
onClick={() =>
navigate(`/user/${encodeURIComponent(comment.user.username)}`)
}
>
<div className="w-8 rounded-full">
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
</div>
</div>
<div className={"w-2"}></div>
<div
className={"text-sm font-bold cursor-pointer"}
onClick={() => {
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
}}
>
{comment.user.username}
</div>
<div className={"grow"}></div>
<Badge className={"badge-soft badge-primary badge-sm"}>
{new Date(comment.created_at).toLocaleString()}
</Badge>
</div>
<div className={"text-sm p-2 whitespace-pre-wrap"}>
{displayContent}
{isLongComment && (
<div className={"flex items-center justify-center"}>
<button
onClick={() => setExpanded(!expanded)}
className="mt-2 text-primary text-sm cursor-pointer flex items-center"
>
{expanded ? <MdArrowUpward /> : <MdArrowDownward />}
<span className={"w-1"}></span>
{expanded ? t("Show less") : t("Show more")}
</button>
</div>
)}
</div>
<ImageGrid images={comment.images} />
{app.user?.id === comment.user.id && (
<div className={"flex flex-row-reverse"}>
<DeleteCommentDialog commentId={comment.id} />
<EditCommentDialog comment={comment} />
</div>
)}
</div>
);
}
function DeleteFileDialog({ function DeleteFileDialog({
fileId, fileId,
uploaderId, uploaderId,
@@ -1509,234 +1304,3 @@ function DeleteFileDialog({
</> </>
); );
} }
function EditCommentDialog({ comment }: { comment: Comment }) {
const [isLoading, setLoading] = useState(false);
const [content, setContent] = useState(comment.content);
const { t } = useTranslation();
const reload = useContext(context);
const [existingImages, setExistingImages] = useState(comment.images);
const [newImages, setNewImages] = useState<File[]>([]);
const handleUpdate = async () => {
if (isLoading) {
return;
}
setLoading(true);
const imageIds: number[] = [];
for (const existingImage of existingImages) {
imageIds.push(existingImage.id);
}
for (const newImage of newImages) {
const res = await network.uploadImage(newImage);
if (res.success) {
imageIds.push(res.data!);
} else {
showToast({
message: res.message,
type: "error",
parent: document.getElementById(`dialog_box`),
});
setLoading(false);
return;
}
}
const res = await network.updateComment(comment.id, content, imageIds);
const dialog = document.getElementById(
`edit_comment_dialog_${comment.id}`,
) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({
message: t("Comment updated successfully"),
type: "success",
});
reload();
} else {
showToast({
message: res.message,
type: "error",
parent: document.getElementById(`dialog_box`),
});
}
setLoading(false);
};
return (
<>
<button
className={"btn btn-sm btn-ghost ml-1"}
onClick={() => {
const dialog = document.getElementById(
`edit_comment_dialog_${comment.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineEdit size={16} className={"inline-block"} />
{t("Edit")}
</button>
<dialog id={`edit_comment_dialog_${comment.id}`} className="modal">
<div className="modal-box" id={"dialog_box"}>
<h3 className="font-bold text-lg">{t("Edit Comment")}</h3>
<TextArea
label={t("Content")}
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<div className={"flex flex-col my-2"}>
<p className={"text-sm font-bold mb-2"}>{t("Images")}</p>
<div className={"grid grid-cols-4 sm:grid-cols-5 gap-2"}>
{existingImages.map((image) => (
<div key={image.id} className={"relative"}>
<SquareImage image={image} />
<button
className={
"btn btn-xs btn-circle btn-error absolute top-1 right-1"
}
onClick={() => {
setExistingImages((prev) =>
prev.filter((i) => i.id !== image.id),
);
}}
>
<MdClose size={14} />
</button>
</div>
))}
{newImages.map((image, index) => (
<div key={index} className={"relative"}>
<img
src={URL.createObjectURL(image)}
alt={`comment-image-${index}`}
className={"rounded-lg aspect-square object-cover"}
/>
<button
className={
"btn btn-xs btn-circle btn-error absolute top-1 right-1"
}
onClick={() => {
setNewImages((prev) =>
prev.filter((_, i) => i !== index),
);
}}
>
<MdClose size={14} />
</button>
</div>
))}
</div>
<div className={"flex"}>
<button
className={"btn btn-sm mt-2"}
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (
(files?.length ?? 0) +
existingImages.length +
newImages.length >
9
) {
showToast({
message: t("You can only upload up to 9 images"),
type: "error",
parent: document.getElementById(`dialog_box`),
});
return;
}
if (files) {
setNewImages((prev) => [...prev, ...Array.from(files)]);
}
};
input.click();
}}
>
<MdAdd size={18} />
{t("Add")}
</button>
</div>
</div>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className="btn btn-primary" onClick={handleUpdate}>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Submit")}
</button>
</div>
</div>
</dialog>
</>
);
}
// 新增:删除评论弹窗组件
function DeleteCommentDialog({ commentId }: { commentId: number }) {
const [isLoading, setLoading] = useState(false);
const reload = useContext(context);
const { t } = useTranslation();
const id = `delete_comment_dialog_${commentId}`;
const handleDelete = async () => {
if (isLoading) return;
setLoading(true);
const res = await network.deleteComment(commentId);
const dialog = document.getElementById(id) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({
message: t("Comment deleted successfully"),
type: "success",
});
reload();
} else {
showToast({ message: res.message, type: "error" });
}
setLoading(false);
};
return (
<>
<button
className={"btn btn-error btn-sm btn-ghost ml-1"}
onClick={() => {
const dialog = document.getElementById(id) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineDelete size={16} className={"inline-block"} />
{t("Delete")}
</button>
<dialog id={id} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete Comment")}</h3>
<p className="py-4">
{t(
"Are you sure you want to delete this comment? This action cannot be undone.",
)}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className="btn btn-error" onClick={handleDelete}>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Delete")}
</button>
</div>
</div>
</dialog>
</>
);
}

View File

@@ -14,12 +14,31 @@ func AddCommentRoutes(router fiber.Router) {
api.Post("/resource/:resourceID", createResourceComment) api.Post("/resource/:resourceID", createResourceComment)
api.Post("/reply/:commentID", createReplyComment) api.Post("/reply/:commentID", createReplyComment)
api.Get("/resource/:resourceID", listResourceComments) api.Get("/resource/:resourceID", listResourceComments)
api.Get("/reply/:commentID", listResourceComments) api.Get("/reply/:commentID", listReplyComments)
api.Get("/user/:username", listCommentsByUser) api.Get("/user/:username", listCommentsByUser)
api.Get("/:commentID", GetCommentByID)
api.Put("/:commentID", updateComment) api.Put("/:commentID", updateComment)
api.Delete("/:commentID", deleteComment) api.Delete("/:commentID", deleteComment)
} }
func GetCommentByID(c fiber.Ctx) error {
commentIDStr := c.Params("commentID")
commentID, err := strconv.Atoi(commentIDStr)
if err != nil {
return model.NewRequestError("Invalid comment ID")
}
comment, err := service.GetCommentByID(uint(commentID))
if err != nil {
return err
}
return c.JSON(model.Response[model.CommentWithRefView]{
Success: true,
Data: *comment,
Message: "Comment retrieved successfully",
})
}
func createResourceComment(c fiber.Ctx) error { func createResourceComment(c fiber.Ctx) error {
userID, ok := c.Locals("uid").(uint) userID, ok := c.Locals("uid").(uint)
if !ok { if !ok {
@@ -40,7 +59,7 @@ func createResourceComment(c fiber.Ctx) error {
return model.NewRequestError("Content cannot be empty") return model.NewRequestError("Content cannot be empty")
} }
comment, err := service.CreateComment(req, userID, uint(resourceID), c.IP(), model.CommentTypeResource) comment, err := service.CreateComment(req, userID, uint(resourceID), c.IP(), model.CommentTypeResource, c.Host())
if err != nil { if err != nil {
return err return err
} }
@@ -71,7 +90,7 @@ func createReplyComment(c fiber.Ctx) error {
return model.NewRequestError("Content cannot be empty") return model.NewRequestError("Content cannot be empty")
} }
comment, err := service.CreateComment(req, userID, uint(commentID), c.IP(), model.CommentTypeReply) comment, err := service.CreateComment(req, userID, uint(commentID), c.IP(), model.CommentTypeReply, c.Host())
if err != nil { if err != nil {
return err return err
} }
@@ -105,6 +124,29 @@ func listResourceComments(c fiber.Ctx) error {
}) })
} }
func listReplyComments(c fiber.Ctx) error {
commentIDStr := c.Params("commentID")
commentID, err := strconv.Atoi(commentIDStr)
if err != nil {
return model.NewRequestError("Invalid comment ID")
}
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
replies, totalPages, err := service.ListCommentReplies(uint(commentID), page)
if err != nil {
return err
}
return c.JSON(model.PageResponse[model.CommentView]{
Success: true,
Data: replies,
TotalPages: totalPages,
Message: "Replies retrieved successfully",
})
}
func listCommentsByUser(c fiber.Ctx) error { func listCommentsByUser(c fiber.Ctx) error {
username := c.Params("username") username := c.Params("username")
if username == "" { if username == "" {
@@ -151,7 +193,7 @@ func updateComment(c fiber.Ctx) error {
return model.NewRequestError("Content cannot be empty") return model.NewRequestError("Content cannot be empty")
} }
comment, err := service.UpdateComment(uint(commentID), userID, req) comment, err := service.UpdateComment(uint(commentID), userID, req, c.Host())
if err != nil { if err != nil {
return err return err
} }

View File

@@ -6,13 +6,13 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func CreateComment(content string, userID uint, resourceID uint, imageIDs []uint, cType model.CommentType) (model.Comment, error) { func CreateComment(content string, userID uint, refID uint, imageIDs []uint, cType model.CommentType) (model.Comment, error) {
var comment model.Comment var comment model.Comment
err := db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
comment = model.Comment{ comment = model.Comment{
Content: content, Content: content,
UserID: userID, UserID: userID,
RefID: resourceID, RefID: refID,
Type: cType, Type: cType,
} }
if err := tx.Create(&comment).Error; err != nil { if err := tx.Create(&comment).Error; err != nil {
@@ -36,9 +36,16 @@ func CreateComment(content string, userID uint, resourceID uint, imageIDs []uint
return err return err
} }
// Update resource comments count if cType == model.CommentTypeResource {
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).Update("comments", gorm.Expr("comments + 1")).Error; err != nil { // Update resource comments count
return err if err := tx.Model(&model.Resource{}).Where("id = ?", refID).Update("comments", gorm.Expr("comments + 1")).Error; err != nil {
return err
}
} else if cType == model.CommentTypeReply {
// Update reply count for the parent comment
if err := tx.Model(&model.Comment{}).Where("id = ?", refID).Update("reply_count", gorm.Expr("reply_count + 1")).Error; err != nil {
return err
}
} }
return nil return nil
@@ -184,3 +191,32 @@ func DeleteCommentByID(commentID uint) error {
return nil return nil
}) })
} }
func GetCommentReplies(commentID uint, page, pageSize int) ([]model.Comment, int, error) {
var replies []model.Comment
var total int64
if err := db.
Model(&model.Comment{}).
Where("type = ?", model.CommentTypeReply).
Where("ref_id = ?", commentID).
Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.
Model(&model.Comment{}).
Where("type = ?", model.CommentTypeReply).
Where("ref_id = ?", commentID).
Offset((page - 1) * pageSize).
Limit(pageSize).
Preload("User").
Order("created_at DESC").
Find(&replies).Error; err != nil {
return nil, 0, err
}
totalPages := (int(total) + pageSize - 1) / pageSize
return replies, totalPages, nil
}

View File

@@ -8,12 +8,13 @@ import (
type Comment struct { type Comment struct {
gorm.Model gorm.Model
Content string `gorm:"not null"` Content string `gorm:"not null"`
RefID uint `gorm:"not null;index:idx_refid_type,priority:1"` RefID uint `gorm:"not null;index:idx_refid_type,priority:1"`
Type CommentType `gorm:"not null;index:idx_refid_type,priority:2"` Type CommentType `gorm:"not null;index:idx_refid_type,priority:2"`
UserID uint `gorm:"not null"` UserID uint `gorm:"not null"`
User User `gorm:"foreignKey:UserID"` User User `gorm:"foreignKey:UserID"`
Images []Image `gorm:"many2many:comment_images;"` Images []Image `gorm:"many2many:comment_images;"`
ReplyCount uint `gorm:"default:0;not null"`
} }
type CommentType uint type CommentType uint
@@ -24,11 +25,13 @@ const (
) )
type CommentView struct { type CommentView struct {
ID uint `json:"id"` ID uint `json:"id"`
Content string `json:"content"` Content string `json:"content"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
User UserView `json:"user"` User UserView `json:"user"`
Images []ImageView `json:"images"` Images []ImageView `json:"images"`
ReplyCount uint `json:"reply_count"`
ContentTruncated bool `json:"content_truncated"`
} }
func (c *Comment) ToView() *CommentView { func (c *Comment) ToView() *CommentView {
@@ -38,21 +41,24 @@ func (c *Comment) ToView() *CommentView {
} }
return &CommentView{ return &CommentView{
ID: c.ID, ID: c.ID,
Content: c.Content, Content: c.Content,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
User: c.User.ToView(), User: c.User.ToView(),
Images: imageViews, Images: imageViews,
ReplyCount: c.ReplyCount,
} }
} }
type CommentWithResourceView struct { type CommentWithResourceView struct {
ID uint `json:"id"` ID uint `json:"id"`
Content string `json:"content"` Content string `json:"content"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Resource ResourceView `json:"resource"` Resource ResourceView `json:"resource"`
User UserView `json:"user"` User UserView `json:"user"`
Images []ImageView `json:"images"` Images []ImageView `json:"images"`
ReplyCount uint `json:"reply_count"`
ContentTruncated bool `json:"content_truncated"`
} }
func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView { func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView {
@@ -62,11 +68,52 @@ func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView {
} }
return &CommentWithResourceView{ return &CommentWithResourceView{
ID: c.ID, ID: c.ID,
Content: c.Content, Content: c.Content,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
Resource: r.ToView(), Resource: r.ToView(),
User: c.User.ToView(), User: c.User.ToView(),
Images: imageViews, Images: imageViews,
ReplyCount: c.ReplyCount,
}
}
type CommentWithRefView struct {
ID uint `json:"id"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
User UserView `json:"user"`
Images []ImageView `json:"images"`
ReplyCount uint `json:"reply_count"`
Resource *ResourceView `json:"resource,omitempty"`
ReplyTo *CommentView `json:"reply_to,omitempty"`
}
func (c *Comment) ToViewWithRef(r *Resource, replyTo *Comment) *CommentWithRefView {
imageViews := make([]ImageView, 0, len(c.Images))
for _, img := range c.Images {
imageViews = append(imageViews, img.ToView())
}
var replyToView *CommentView
if replyTo != nil {
replyToView = replyTo.ToView()
}
var rView *ResourceView
if r != nil {
v := r.ToView()
rView = &v
}
return &CommentWithRefView{
ID: c.ID,
Content: c.Content,
CreatedAt: c.CreatedAt,
User: c.User.ToView(),
Images: imageViews,
ReplyCount: c.ReplyCount,
Resource: rView,
ReplyTo: replyToView,
} }
} }

View File

@@ -4,15 +4,19 @@ import (
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/utils" "nysoure/server/utils"
"regexp"
"strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v3/log" "github.com/gofiber/fiber/v3/log"
) )
const ( const (
maxImagePerComment = 9 maxCommentsPerIP = 512 // Maximum number of comments allowed per IP address per day
maxCommentsPerIP = 512 // Maximum number of comments allowed per IP address per day maxCommentLength = 2048 // Maximum length of a comment
maxCommentLength = 1024 // Maximum length of a comment maxCommentBriefLines = 16 // Maximum number of lines in a comment brief
maxCommentBriefLength = 256 // Maximum length of a comment brief
) )
var ( var (
@@ -20,11 +24,44 @@ var (
) )
type CommentRequest struct { type CommentRequest struct {
Content string `json:"content"` Content string `json:"content"` // markdown
Images []uint `json:"images"` // Images []uint `json:"images"` // Unrequired after new design
} }
func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType model.CommentType) (*model.CommentView, error) { func findImagesInContent(content string, host string) []uint {
// Handle both absolute and relative URLs
absolutePattern := `!\[.*?\]\((?:https?://` + host + `)?/api/image/(\d+)(?:\s+["'].*?["'])?\)`
relativePattern := `!\[.*?\]\(/api/image/(\d+)(?:\s+["'].*?["'])?\)`
// Combine patterns and compile regex
patterns := []string{absolutePattern, relativePattern}
// Store unique image IDs to avoid duplicates
imageIDs := make(map[uint]struct{})
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(content, -1)
for _, match := range matches {
if len(match) >= 2 {
if id, err := strconv.ParseUint(match[1], 10, 32); err == nil {
imageIDs[uint(id)] = struct{}{}
}
}
}
}
// Convert map keys to slice
result := make([]uint, 0, len(imageIDs))
for id := range imageIDs {
result = append(result, id)
}
return result
}
func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType model.CommentType, host string) (*model.CommentView, error) {
if !commentsLimiter.AllowRequest(ip) { if !commentsLimiter.AllowRequest(ip) {
log.Warnf("IP %s has exceeded the comment limit of %d comments per day", ip, maxCommentsPerIP) log.Warnf("IP %s has exceeded the comment limit of %d comments per day", ip, maxCommentsPerIP)
return nil, model.NewRequestError("Too many comments from this IP address, please try again later") return nil, model.NewRequestError("Too many comments from this IP address, please try again later")
@@ -37,9 +74,6 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters") return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters")
} }
if len(req.Images) > maxImagePerComment {
return nil, model.NewRequestError("Too many images, maximum is 9")
}
resourceExists, err := dao.ExistsResource(refID) resourceExists, err := dao.ExistsResource(refID)
if err != nil { if err != nil {
log.Error("Error checking resource existence:", err) log.Error("Error checking resource existence:", err)
@@ -56,7 +90,10 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
if !userExists { if !userExists {
return nil, model.NewNotFoundError("User not found") return nil, model.NewNotFoundError("User not found")
} }
c, err := dao.CreateComment(req.Content, userID, refID, req.Images, cType)
images := findImagesInContent(req.Content, host)
c, err := dao.CreateComment(req.Content, userID, refID, images, cType)
if err != nil { if err != nil {
log.Error("Error creating comment:", err) log.Error("Error creating comment:", err)
return nil, model.NewInternalServerError("Error creating comment") return nil, model.NewInternalServerError("Error creating comment")
@@ -68,6 +105,41 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
return c.ToView(), nil return c.ToView(), nil
} }
func restrictCommentLength(content string) (c string, truncated bool) {
lines := strings.Split(content, "\n")
lineCount := 0
for i, line := range lines {
reg := regexp.MustCompile(`!\[.*?\]\(.*?\)`)
if reg.MatchString(line) {
// Count the line with image as 5 lines
lineCount += 5
} else {
lineCount++
}
if lineCount > maxCommentBriefLines {
lines = lines[:i+1] // Keep the current line
content = strings.Join(lines, "\n")
truncated = true
break
}
}
if len([]rune(content)) > maxCommentBriefLength {
i := len(lines) - 1
for count := len([]rune(content)); count > maxCommentBriefLength && i > 0; i-- {
count -= len([]rune(lines[i]))
}
if i == 0 && len([]rune(lines[0])) > maxCommentBriefLength {
content = string([]rune(lines[0])[:maxCommentBriefLength])
} else {
content = strings.Join(lines[:i+1], "\n")
}
truncated = true
}
return content, truncated
}
func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int, error) { func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int, error) {
resourceExists, err := dao.ExistsResource(resourceID) resourceExists, err := dao.ExistsResource(resourceID)
if err != nil { if err != nil {
@@ -84,7 +156,37 @@ func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int,
} }
res := make([]model.CommentView, 0, len(comments)) res := make([]model.CommentView, 0, len(comments))
for _, c := range comments { for _, c := range comments {
res = append(res, *c.ToView()) v := *c.ToView()
var truncated bool
v.Content, truncated = restrictCommentLength(v.Content)
v.ContentTruncated = truncated
res = append(res, v)
}
return res, totalPages, nil
}
func ListCommentReplies(commentID uint, page int) ([]model.CommentView, int, error) {
comment, err := dao.GetCommentByID(commentID)
if err != nil {
log.Error("Error getting comment:", err)
return nil, 0, model.NewNotFoundError("Comment not found")
}
if comment.Type != model.CommentTypeReply {
return nil, 0, model.NewRequestError("This comment is not a reply")
}
replies, totalPages, err := dao.GetCommentReplies(commentID, page, pageSize)
if err != nil {
log.Error("Error getting replies:", err)
return nil, 0, model.NewInternalServerError("Error getting replies")
}
res := make([]model.CommentView, 0, len(replies))
for _, r := range replies {
v := *r.ToView()
var truncated bool
v.Content, truncated = restrictCommentLength(v.Content)
v.ContentTruncated = truncated
res = append(res, v)
} }
return res, totalPages, nil return res, totalPages, nil
} }
@@ -102,12 +204,16 @@ func ListCommentsWithUser(username string, page int) ([]model.CommentWithResourc
log.Error("Error getting resource for comment:", err) log.Error("Error getting resource for comment:", err)
return nil, 0, model.NewInternalServerError("Error getting resource for comment") return nil, 0, model.NewInternalServerError("Error getting resource for comment")
} }
res = append(res, *c.ToViewWithResource(&r)) v := *c.ToViewWithResource(&r)
var truncated bool
v.Content, truncated = restrictCommentLength(v.Content)
v.ContentTruncated = truncated
res = append(res, v)
} }
return res, totalPages, nil return res, totalPages, nil
} }
func UpdateComment(commentID, userID uint, req CommentRequest) (*model.CommentView, error) { func UpdateComment(commentID, userID uint, req CommentRequest, host string) (*model.CommentView, error) {
if len(req.Content) == 0 { if len(req.Content) == 0 {
return nil, model.NewRequestError("Content cannot be empty") return nil, model.NewRequestError("Content cannot be empty")
} }
@@ -115,17 +221,22 @@ func UpdateComment(commentID, userID uint, req CommentRequest) (*model.CommentVi
return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters") return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters")
} }
if len(req.Images) > maxImagePerComment {
return nil, model.NewRequestError("Too many images, maximum is 9")
}
comment, err := dao.GetCommentByID(commentID) comment, err := dao.GetCommentByID(commentID)
if err != nil { if err != nil {
return nil, model.NewNotFoundError("Comment not found") return nil, model.NewNotFoundError("Comment not found")
} }
if comment.UserID != userID { if comment.UserID != userID {
return nil, model.NewRequestError("You can only update your own comments") isAdmin, err := CheckUserIsAdmin(userID)
if err != nil {
log.Error("Error checking if user is admin:", err)
return nil, model.NewInternalServerError("Error checking user permissions")
}
if !isAdmin {
return nil, model.NewUnAuthorizedError("You can only update your own comments")
}
} }
updated, err := dao.UpdateCommentContent(commentID, req.Content, req.Images) images := findImagesInContent(req.Content, host)
updated, err := dao.UpdateCommentContent(commentID, req.Content, images)
if err != nil { if err != nil {
return nil, model.NewInternalServerError("Error updating comment") return nil, model.NewInternalServerError("Error updating comment")
} }
@@ -145,3 +256,31 @@ func DeleteComment(commentID, userID uint) error {
} }
return nil return nil
} }
func GetCommentByID(commentID uint) (*model.CommentWithRefView, error) {
comment, err := dao.GetCommentByID(commentID)
if err != nil {
return nil, model.NewNotFoundError("Comment not found")
}
var resource *model.Resource
var replyTo *model.Comment
if comment.Type == model.CommentTypeResource {
r, err := dao.GetResourceByID(comment.RefID)
if err != nil {
log.Error("Error getting resource for comment:", err)
return nil, model.NewInternalServerError("Error getting resource for comment")
}
resource = &r
} else {
reply, err := dao.GetCommentByID(comment.RefID)
if err != nil {
log.Error("Error getting reply for comment:", err)
return nil, model.NewInternalServerError("Error getting reply for comment")
}
replyTo = reply
}
return comment.ToViewWithRef(resource, replyTo), nil
}