mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 20:27:23 +00:00
487 lines
13 KiB
TypeScript
487 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { network } from "../network/network";
|
|
import showToast from "../components/toast";
|
|
import { useNavigate, useParams } from "react-router";
|
|
import { useTranslation } from "../utils/i18n";
|
|
import { CommentWithRef, Resource } from "../network/models";
|
|
import Loading from "../components/loading";
|
|
import Markdown from "react-markdown";
|
|
import Badge from "../components/badge";
|
|
import { CommentInput } from "../components/comment_input";
|
|
import { CommentTile } from "../components/comment_tile";
|
|
import { Comment } from "../network/models";
|
|
import Pagination from "../components/pagination";
|
|
import { MdOutlineDelete, MdOutlineEdit } from "react-icons/md";
|
|
import { TextArea } from "../components/input";
|
|
import { app } from "../app";
|
|
import { useNavigator } from "../components/navigator";
|
|
|
|
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(() => {
|
|
setComment(null);
|
|
const id = parseInt(commentId || "0");
|
|
if (isNaN(id) || id <= 0) {
|
|
showToast({
|
|
message: t("Invalid comment ID"),
|
|
type: "error",
|
|
});
|
|
return;
|
|
}
|
|
const preFetchData = app.getPreFetchData();
|
|
if (preFetchData?.comment?.id === id) {
|
|
setComment(preFetchData.comment);
|
|
return;
|
|
}
|
|
network.getComment(id).then((res) => {
|
|
if (res.success) {
|
|
setComment(res.data!);
|
|
} else {
|
|
showToast({
|
|
message: res.message,
|
|
type: "error",
|
|
});
|
|
}
|
|
});
|
|
}, [commentId]);
|
|
|
|
const onUpdated = useCallback(() => {
|
|
setComment(null);
|
|
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",
|
|
});
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const onDeleted = useCallback(() => {
|
|
// check history length
|
|
if (window.history.length > 1) {
|
|
// go back to the previous page
|
|
navigate(-1);
|
|
} else {
|
|
// if there is no previous page, go to the home page
|
|
navigate("/");
|
|
}
|
|
}, [navigate]);
|
|
|
|
useEffect(() => {
|
|
document.title = t("Comment Details");
|
|
}, [t]);
|
|
|
|
const navigator = useNavigator();
|
|
|
|
useEffect(() => {
|
|
if (comment?.resource && comment.resource.image) {
|
|
navigator.setBackground(
|
|
network.getResampledImageUrl(comment.resource.image.id),
|
|
);
|
|
} else if (comment?.images?.length) {
|
|
// comment images are not resampled
|
|
navigator.setBackground(network.getImageUrl(comment.images[0].id));
|
|
}
|
|
}, [comment]);
|
|
|
|
if (!comment) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="p-4">
|
|
{comment.resource && <ResourceCard resource={comment.resource} />}
|
|
{comment.reply_to && <CommentTile comment={comment.reply_to} />}
|
|
<div className="h-2"></div>
|
|
<div className="bg-base-100-tr82 rounded-2xl p-4 shadow">
|
|
<div className="flex items-center">
|
|
<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>
|
|
<span className="text-xs text-base-content/80 ml-2">
|
|
{t("Commented on")}
|
|
{new Date(comment.created_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
<article>
|
|
<CommentContent content={comment.content} />
|
|
</article>
|
|
{app.user?.id === comment.user.id && (
|
|
<div className="flex flex-row justify-end mt-2">
|
|
<EditCommentDialog comment={comment} onUpdated={onUpdated} />
|
|
<DeleteCommentDialog commentId={comment.id} onUpdated={onDeleted} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="h-4" />
|
|
<div className="border-t border-base-300" />
|
|
<div className="h-4" />
|
|
<CommentReply comment={comment} />
|
|
</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-col sm:flex-row w-full card bg-base-100-tr82 shadow hover:shadow-md overflow-clip my-2 transition-shadow"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
navigate(link);
|
|
}}
|
|
>
|
|
{resource.image != null && (
|
|
<img
|
|
className="object-cover w-full max-h-40 sm:w-40 md:w-44 lg:w-52 sm: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 className="mb-2">
|
|
{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>
|
|
);
|
|
}
|
|
|
|
function CommentReply({ comment }: { comment: CommentWithRef }) {
|
|
const { t } = useTranslation();
|
|
|
|
const [page, setPage] = useState(1);
|
|
const [maxPage, setMaxPage] = useState(0);
|
|
const [listKey, setListKey] = useState(0);
|
|
|
|
const reload = useCallback(() => {
|
|
setPage(1);
|
|
setMaxPage(0);
|
|
setListKey((prev) => prev + 1);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<h2 className="text-xl font-bold my-2">{t("Replies")}</h2>
|
|
<CommentInput replyTo={comment.id} reload={reload} />
|
|
<CommentsList
|
|
commentId={comment.id}
|
|
page={page}
|
|
maxPageCallback={(maxPage: number) => {
|
|
setMaxPage(maxPage);
|
|
}}
|
|
key={listKey}
|
|
reload={reload}
|
|
/>
|
|
{maxPage ? (
|
|
<div className={"w-full flex justify-center"}>
|
|
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CommentsList({
|
|
commentId,
|
|
page,
|
|
maxPageCallback,
|
|
reload,
|
|
}: {
|
|
commentId: number;
|
|
page: number;
|
|
maxPageCallback: (maxPage: number) => void;
|
|
reload: () => void;
|
|
}) {
|
|
const [comments, setComments] = useState<Comment[] | null>(null);
|
|
|
|
useEffect(() => {
|
|
network.listCommentReplies(commentId, page).then((res) => {
|
|
if (res.success) {
|
|
setComments(res.data!);
|
|
maxPageCallback(res.totalPages || 1);
|
|
} else {
|
|
showToast({
|
|
message: res.message,
|
|
type: "error",
|
|
});
|
|
}
|
|
});
|
|
}, [maxPageCallback, page, commentId]);
|
|
|
|
if (comments == null) {
|
|
return (
|
|
<div className={"w-full"}>
|
|
<Loading />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{comments.map((comment) => {
|
|
return (
|
|
<CommentTile
|
|
elevation="high"
|
|
comment={comment}
|
|
key={comment.id}
|
|
onUpdated={reload}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function EditCommentDialog({
|
|
comment,
|
|
onUpdated,
|
|
}: {
|
|
comment: CommentWithRef;
|
|
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={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
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"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<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">
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => {
|
|
const dialog = document.getElementById(
|
|
`edit_comment_dialog_${comment.id}`,
|
|
) as HTMLDialogElement;
|
|
dialog.close();
|
|
}}
|
|
>
|
|
{t("Close")}
|
|
</button>
|
|
<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={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const dialog = document.getElementById(id) as HTMLDialogElement;
|
|
dialog.showModal();
|
|
}}
|
|
>
|
|
<MdOutlineDelete size={16} className={"inline-block"} />
|
|
{t("Delete")}
|
|
</button>
|
|
<dialog
|
|
id={id}
|
|
className="modal"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<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">
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => {
|
|
const dialog = document.getElementById(id) as HTMLDialogElement;
|
|
dialog.close();
|
|
}}
|
|
>
|
|
{t("Close")}
|
|
</button>
|
|
<button className="btn btn-error" onClick={handleDelete}>
|
|
{isLoading ? (
|
|
<span className={"loading loading-spinner loading-sm"}></span>
|
|
) : null}
|
|
{t("Delete")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
</>
|
|
);
|
|
}
|