-
{resource.title}
+
{resource.title}
{tags.map((tag) => {
diff --git a/frontend/src/markdown.css b/frontend/src/markdown.css
index 867a678..9606cee 100644
--- a/frontend/src/markdown.css
+++ b/frontend/src/markdown.css
@@ -1,6 +1,7 @@
article {
& {
color: var(--color-base-content);
+ word-break: break-all;
}
h1 {
@@ -9,59 +10,70 @@ article {
padding: 12px 0;
margin: 24px 0 12px;
}
+
h2 {
font-size: 20px;
font-weight: bold;
padding: 12px 0;
margin: 16px 0 8px;
}
+
h3 {
font-size: 16px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 4px;
}
+
h4 {
font-size: 14px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 4px;
}
+
h5 {
font-size: 12px;
font-weight: bold;
padding: 4px 0;
}
+
h6 {
font-size: 10px;
font-weight: bold;
padding: 2px 0;
}
+
p {
font-size: 14px;
line-height: 1.6;
margin: 12px 0;
}
+
ul {
list-style-type: disc;
margin: 0 0 16px 20px;
padding: 0;
+
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
}
+
ol {
list-style-type: decimal;
margin: 0 0 16px 20px;
padding: 0;
+
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
}
+
blockquote {
font-size: 14px;
line-height: 1.5;
@@ -70,25 +82,31 @@ article {
border-left: 4px solid var(--color-base-300);
background-color: var(--color-base-200);
}
+
hr {
border: 0;
border-top: 1px solid var(--color-base-300);
margin: 16px 0;
}
+
a {
color: var(--color-primary);
text-decoration: none;
+
&:hover {
text-decoration: underline;
}
}
+
img {
border-radius: 8px;
max-height: 400px;
}
+
p:has(> img) {
margin: 16px 0;
}
+
p code {
background-color: var(--color-base-200);
padding: 2px 4px;
@@ -96,15 +114,141 @@ article {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
}
+
iframe {
width: 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 {
text-decoration: none;
+
&:hover {
text-decoration: none;
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/network/models.ts b/frontend/src/network/models.ts
index 250c738..a0e441f 100644
--- a/frontend/src/network/models.ts
+++ b/frontend/src/network/models.ts
@@ -120,6 +120,8 @@ export interface Comment {
created_at: string;
user: User;
images: Image[];
+ content_truncated: boolean;
+ reply_count: number;
}
export interface CommentWithResource {
@@ -129,6 +131,19 @@ export interface CommentWithResource {
user: User;
images: Image[];
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 {
diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts
index acd8931..56fb7b2 100644
--- a/frontend/src/network/network.ts
+++ b/frontend/src/network/network.ts
@@ -18,6 +18,7 @@ import {
RSort,
TagWithCount,
Activity,
+ CommentWithRef,
} from "./models.ts";
class Network {
@@ -574,12 +575,10 @@ class Network {
async createResourceComment(
resourceID: number,
content: string,
- images: number[],
): Promise> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/comments/resource/${resourceID}`, {
content,
- images,
}),
);
}
@@ -587,12 +586,21 @@ class Network {
async updateComment(
commentID: number,
content: string,
- images: number[],
): Promise> {
return this._callApi(() =>
axios.put(`${this.apiBaseUrl}/comments/${commentID}`, {
content,
- images,
+ }),
+ );
+ }
+
+ async replyToComment(
+ commentID: number,
+ content: string,
+ ): Promise> {
+ return this._callApi(() =>
+ axios.post(`${this.apiBaseUrl}/comments/reply/${commentID}`, {
+ content,
}),
);
}
@@ -622,6 +630,12 @@ class Network {
);
}
+ async getComment(commentID: number): Promise> {
+ return this._callApi(() =>
+ axios.get(`${this.apiBaseUrl}/comments/${commentID}`),
+ );
+ }
+
async deleteComment(commentID: number): Promise> {
return this._callApi(() =>
axios.delete(`${this.apiBaseUrl}/comments/${commentID}`),
diff --git a/frontend/src/pages/comment_page.tsx b/frontend/src/pages/comment_page.tsx
new file mode 100644
index 0000000..f3556ed
--- /dev/null
+++ b/frontend/src/pages/comment_page.tsx
@@ -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(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 ;
+ }
+
+ return (
+
+
{t("Comment")}
+
+ {comment.resource &&
}
+
+
+
+
+
+ );
+}
+
+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 {content};
+}
+
+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 (
+ {
+ e.preventDefault();
+ navigate(link);
+ }}
+ >
+ {resource.image != null && (
+
+ )}
+
+
{resource.title}
+
+
+ {tags.map((tag) => {
+ return (
+
+ {tag.name}
+
+ );
+ })}
+
+
+
+
+
+
})
+
+
+
+
{resource.author.username}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/resource_details_page.tsx b/frontend/src/pages/resource_details_page.tsx
index 2a02af5..6c7ae6d 100644
--- a/frontend/src/pages/resource_details_page.tsx
+++ b/frontend/src/pages/resource_details_page.tsx
@@ -24,22 +24,18 @@ import "../markdown.css";
import Loading from "../components/loading.tsx";
import {
MdAdd,
- MdArrowDownward,
- MdArrowUpward,
- MdClose,
MdOutlineArticle,
MdOutlineComment,
MdOutlineDataset,
MdOutlineDelete,
MdOutlineDownload,
MdOutlineEdit,
- MdOutlineImage,
MdOutlineLink,
MdOutlineOpenInNew,
} from "react-icons/md";
import { app } from "../app.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 Pagination from "../components/pagination.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 Input, { TextArea } from "../components/input.tsx";
import { useAppContext } from "../components/AppContext.tsx";
-import { ImageGrid, SquareImage } from "../components/image.tsx";
import { BiLogoSteam } from "react-icons/bi";
+import { CommentTile } from "../components/comment_tile.tsx";
+import { CommentInput } from "../components/comment_input.tsx";
export default function ResourcePage() {
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([]);
- 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 (
-
- );
- }
-
- return (
-
- );
-}
-
function CommentsList({
resourceId,
page,
@@ -1345,6 +1201,8 @@ function CommentsList({
}) {
const [comments, setComments] = useState(null);
+ const reload = useContext(context);
+
useEffect(() => {
network.listResourceComments(resourceId, page).then((res) => {
if (res.success) {
@@ -1370,77 +1228,14 @@ function CommentsList({
return (
<>
{comments.map((comment) => {
- return ;
+ return (
+
+ );
})}
>
);
}
-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 (
-
-
-
- navigate(`/user/${encodeURIComponent(comment.user.username)}`)
- }
- >
-
-
})
-
-
-
-
{
- navigate(`/user/${encodeURIComponent(comment.user.username)}`);
- }}
- >
- {comment.user.username}
-
-
-
- {new Date(comment.created_at).toLocaleString()}
-
-
-
- {displayContent}
- {isLongComment && (
-
-
-
- )}
-
-
- {app.user?.id === comment.user.id && (
-
-
-
-
- )}
-
- );
-}
-
function DeleteFileDialog({
fileId,
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([]);
-
- 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 (
- <>
-
-
- >
- );
-}
-
-// 新增:删除评论弹窗组件
-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 (
- <>
-
-
- >
- );
-}
diff --git a/server/api/comment.go b/server/api/comment.go
index f77e805..c5a5fda 100644
--- a/server/api/comment.go
+++ b/server/api/comment.go
@@ -14,12 +14,31 @@ func AddCommentRoutes(router fiber.Router) {
api.Post("/resource/:resourceID", createResourceComment)
api.Post("/reply/:commentID", createReplyComment)
api.Get("/resource/:resourceID", listResourceComments)
- api.Get("/reply/:commentID", listResourceComments)
+ api.Get("/reply/:commentID", listReplyComments)
api.Get("/user/:username", listCommentsByUser)
+ api.Get("/:commentID", GetCommentByID)
api.Put("/:commentID", updateComment)
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 {
userID, ok := c.Locals("uid").(uint)
if !ok {
@@ -40,7 +59,7 @@ func createResourceComment(c fiber.Ctx) error {
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 {
return err
}
@@ -71,7 +90,7 @@ func createReplyComment(c fiber.Ctx) error {
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 {
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 {
username := c.Params("username")
if username == "" {
@@ -151,7 +193,7 @@ func updateComment(c fiber.Ctx) error {
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 {
return err
}
diff --git a/server/dao/comment.go b/server/dao/comment.go
index 4277353..fbae3b5 100644
--- a/server/dao/comment.go
+++ b/server/dao/comment.go
@@ -6,13 +6,13 @@ import (
"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
err := db.Transaction(func(tx *gorm.DB) error {
comment = model.Comment{
Content: content,
UserID: userID,
- RefID: resourceID,
+ RefID: refID,
Type: cType,
}
if err := tx.Create(&comment).Error; err != nil {
@@ -36,9 +36,16 @@ func CreateComment(content string, userID uint, resourceID uint, imageIDs []uint
return err
}
- // Update resource comments count
- if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).Update("comments", gorm.Expr("comments + 1")).Error; err != nil {
- return err
+ if cType == model.CommentTypeResource {
+ // Update resource comments count
+ 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
@@ -184,3 +191,32 @@ func DeleteCommentByID(commentID uint) error {
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
+}
diff --git a/server/model/comment.go b/server/model/comment.go
index af22522..e44d844 100644
--- a/server/model/comment.go
+++ b/server/model/comment.go
@@ -8,12 +8,13 @@ import (
type Comment struct {
gorm.Model
- Content string `gorm:"not null"`
- RefID uint `gorm:"not null;index:idx_refid_type,priority:1"`
- Type CommentType `gorm:"not null;index:idx_refid_type,priority:2"`
- UserID uint `gorm:"not null"`
- User User `gorm:"foreignKey:UserID"`
- Images []Image `gorm:"many2many:comment_images;"`
+ Content string `gorm:"not null"`
+ RefID uint `gorm:"not null;index:idx_refid_type,priority:1"`
+ Type CommentType `gorm:"not null;index:idx_refid_type,priority:2"`
+ UserID uint `gorm:"not null"`
+ User User `gorm:"foreignKey:UserID"`
+ Images []Image `gorm:"many2many:comment_images;"`
+ ReplyCount uint `gorm:"default:0;not null"`
}
type CommentType uint
@@ -24,11 +25,13 @@ const (
)
type CommentView struct {
- ID uint `json:"id"`
- Content string `json:"content"`
- CreatedAt time.Time `json:"created_at"`
- User UserView `json:"user"`
- Images []ImageView `json:"images"`
+ 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"`
+ ContentTruncated bool `json:"content_truncated"`
}
func (c *Comment) ToView() *CommentView {
@@ -38,21 +41,24 @@ func (c *Comment) ToView() *CommentView {
}
return &CommentView{
- ID: c.ID,
- Content: c.Content,
- CreatedAt: c.CreatedAt,
- User: c.User.ToView(),
- Images: imageViews,
+ ID: c.ID,
+ Content: c.Content,
+ CreatedAt: c.CreatedAt,
+ User: c.User.ToView(),
+ Images: imageViews,
+ ReplyCount: c.ReplyCount,
}
}
type CommentWithResourceView struct {
- ID uint `json:"id"`
- Content string `json:"content"`
- CreatedAt time.Time `json:"created_at"`
- Resource ResourceView `json:"resource"`
- User UserView `json:"user"`
- Images []ImageView `json:"images"`
+ ID uint `json:"id"`
+ Content string `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+ Resource ResourceView `json:"resource"`
+ User UserView `json:"user"`
+ Images []ImageView `json:"images"`
+ ReplyCount uint `json:"reply_count"`
+ ContentTruncated bool `json:"content_truncated"`
}
func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView {
@@ -62,11 +68,52 @@ func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView {
}
return &CommentWithResourceView{
- ID: c.ID,
- Content: c.Content,
- CreatedAt: c.CreatedAt,
- Resource: r.ToView(),
- User: c.User.ToView(),
- Images: imageViews,
+ ID: c.ID,
+ Content: c.Content,
+ CreatedAt: c.CreatedAt,
+ Resource: r.ToView(),
+ User: c.User.ToView(),
+ 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,
}
}
diff --git a/server/service/comment.go b/server/service/comment.go
index ef56f80..bae4204 100644
--- a/server/service/comment.go
+++ b/server/service/comment.go
@@ -4,15 +4,19 @@ import (
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/utils"
+ "regexp"
+ "strconv"
+ "strings"
"time"
"github.com/gofiber/fiber/v3/log"
)
const (
- maxImagePerComment = 9
- maxCommentsPerIP = 512 // Maximum number of comments allowed per IP address per day
- maxCommentLength = 1024 // Maximum length of a comment
+ maxCommentsPerIP = 512 // Maximum number of comments allowed per IP address per day
+ maxCommentLength = 2048 // Maximum length of a comment
+ maxCommentBriefLines = 16 // Maximum number of lines in a comment brief
+ maxCommentBriefLength = 256 // Maximum length of a comment brief
)
var (
@@ -20,11 +24,44 @@ var (
)
type CommentRequest struct {
- Content string `json:"content"`
- Images []uint `json:"images"`
+ Content string `json:"content"` // markdown
+ // 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) {
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")
@@ -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")
}
- if len(req.Images) > maxImagePerComment {
- return nil, model.NewRequestError("Too many images, maximum is 9")
- }
resourceExists, err := dao.ExistsResource(refID)
if err != nil {
log.Error("Error checking resource existence:", err)
@@ -56,7 +90,10 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
if !userExists {
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 {
log.Error("Error creating comment:", err)
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
}
+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) {
resourceExists, err := dao.ExistsResource(resourceID)
if err != nil {
@@ -84,7 +156,37 @@ func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int,
}
res := make([]model.CommentView, 0, len(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
}
@@ -102,12 +204,16 @@ func ListCommentsWithUser(username string, page int) ([]model.CommentWithResourc
log.Error("Error getting resource for comment:", err)
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
}
-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 {
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")
}
- if len(req.Images) > maxImagePerComment {
- return nil, model.NewRequestError("Too many images, maximum is 9")
- }
comment, err := dao.GetCommentByID(commentID)
if err != nil {
return nil, model.NewNotFoundError("Comment not found")
}
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 {
return nil, model.NewInternalServerError("Error updating comment")
}
@@ -145,3 +256,31 @@ func DeleteComment(commentID, userID uint) error {
}
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
+
+}