Feat: 新增评论组件,细化通知组件样式,引入环境变量,替换图标组件
This commit is contained in:
@@ -2,6 +2,8 @@ import { NavLink } from "react-router";
|
||||
import { Feed, GitHub, QQ } from "../ui/icons";
|
||||
import { clsn } from "~/utils";
|
||||
|
||||
const { APP_APIURL } = import.meta.env;
|
||||
|
||||
const navItems = [
|
||||
{ name: "首页", to: "/" },
|
||||
{ name: "日记", to: "/note" },
|
||||
@@ -34,13 +36,13 @@ function Header() {
|
||||
))}
|
||||
</nav>
|
||||
<nav className="flex items-center gap-2 [&>a]:hover:text-pink-400 [&>a]:transition-colors">
|
||||
<a href="https://paul.ren/join-group" target="_blank" rel="noreferrer" title="企鹅群">
|
||||
<a href={`${APP_APIURL}/join-group`} target="_blank" rel="noreferrer" title="企鹅群">
|
||||
<QQ className="w-5" />
|
||||
</a>
|
||||
<a href="https://github.com/Dreamer-Paul" target="_blank" rel="noreferrer" title="GitHub">
|
||||
<GitHub className="w-5" />
|
||||
</a>
|
||||
<a href="https://paul.ren/feed" target="_blank" title="订阅 RSS">
|
||||
<a href={`${APP_APIURL}/feed`} target="_blank" title="订阅 RSS">
|
||||
<Feed className="w-5" />
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, type ChangeEvent, type SubmitEvent } from "react";
|
||||
import { RiLoader5Fill } from "@remixicon/react";
|
||||
|
||||
import { add } from "~/components/ui/notice/utils";
|
||||
import { useIsLogin } from "~/composables/use-is-login";
|
||||
import { clsn } from "~/utils";
|
||||
import { getAuthorInfo, setAuthorInfo } from "~/utils/comment";
|
||||
|
||||
export interface CommentFormSubmitValues {
|
||||
name: string;
|
||||
email: string;
|
||||
site: string;
|
||||
content: string;
|
||||
root?: string;
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
interface CommentFormProps {
|
||||
className?: string;
|
||||
parent?: string;
|
||||
root?: string;
|
||||
onSubmit: (values: CommentFormSubmitValues) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function CommentForm({ className, parent, root, onSubmit }: CommentFormProps) {
|
||||
const isLogin = useIsLogin();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [input, setInput] = useState(() => ({
|
||||
...getAuthorInfo(),
|
||||
content: "",
|
||||
}));
|
||||
|
||||
const onChange = (ev: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = ev.target;
|
||||
setInput((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const onFormSubmit = (ev: SubmitEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!isLogin && (!input.name || !input.email || !input.content)) {
|
||||
add({
|
||||
title: "评论失败",
|
||||
type: "error",
|
||||
content: "缺少必填项",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.content) {
|
||||
add({
|
||||
title: "评论失败",
|
||||
type: "error",
|
||||
content: "缺少必填项",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthorInfo({
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
site: input.site,
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
|
||||
onSubmit({
|
||||
...input,
|
||||
root,
|
||||
parent,
|
||||
}).then(() => {
|
||||
setInput((prev) => ({ ...prev, content: "" }));
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const inputClass = "w-full px-3 py-2.5 rounded-xl bg-white border-4 border-gray-200 focus:border-pink-400 outline-none transition-colors placeholder:text-neutral-400";
|
||||
const labelClass = "block text-sm font-medium mb-2 text-neutral-500";
|
||||
|
||||
if (isLogin) {
|
||||
return (
|
||||
<form className={clsn("mb-8", className)} onSubmit={onFormSubmit}>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-pink-400 font-bold text-lg mb-1">欢迎回来!</h3>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
name="content"
|
||||
className={clsn(inputClass, "min-h-[7em] resize-y")}
|
||||
placeholder="评论"
|
||||
rows={3}
|
||||
required
|
||||
value={input.content}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-xl bg-pink-400 text-white font-medium hover:bg-pink-500 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <RiLoader5Fill className="animate-spin mr-2" />}
|
||||
提交
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={clsn("mb-8", className)} onSubmit={onFormSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>昵称</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className={inputClass}
|
||||
placeholder="园友"
|
||||
required
|
||||
value={input.name}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className={inputClass}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
value={input.email}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>站点</label>
|
||||
<input
|
||||
type="url"
|
||||
name="site"
|
||||
className={inputClass}
|
||||
placeholder="https://example.com"
|
||||
value={input.site}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:h-full">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<label className={labelClass}>评论</label>
|
||||
<textarea
|
||||
name="content"
|
||||
className={clsn(inputClass, "md:flex-1 resize-y")}
|
||||
placeholder="注意言行举止,来做评论排行榜的前十名吧~"
|
||||
rows={3}
|
||||
required
|
||||
value={input.content}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-2.5 rounded-xl border-4 border-transparent bg-pink-400 text-white font-medium hover:bg-pink-500 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <RiLoader5Fill className="animate-spin mr-2" />}
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { RiReplyFill, RiRobotFill } from "@remixicon/react";
|
||||
import CommentForm, { type CommentFormSubmitValues } from "../comment-form";
|
||||
|
||||
interface CommentItemProps {
|
||||
data: API.Comment.ICommentDetailData;
|
||||
onSubmit: (values: CommentFormSubmitValues) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export default function CommentItem({ data, onSubmit }: CommentItemProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const onToggleCommentForm = () => setActive((prev) => !prev);
|
||||
|
||||
const onReplySubmit = (values: CommentFormSubmitValues) => {
|
||||
return onSubmit(values).then(() => {
|
||||
setActive(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scroll-mt-28 mb-8 last:mb-0" id={`c${data.uuid}`}>
|
||||
<div className="flex items-start gap-4 relative group">
|
||||
<img
|
||||
src={data.avatar || ""}
|
||||
alt={data.name || ""}
|
||||
className="w-12 h-12 rounded-xl bg-neutral-200 object-cover shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
className="inline-block text-pink-400 font-medium mb-1 hover:underline"
|
||||
href={data.site || "#"}
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer"
|
||||
>
|
||||
{data.name}
|
||||
{data.is_ai_bot && (
|
||||
<span className="ml-2 text-cyan-500" title="AI 机器人评论">
|
||||
<RiRobotFill className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<p className="whitespace-pre-wrap text-neutral-600 leading-relaxed">
|
||||
{data.content}
|
||||
{data.status === "waiting" && (
|
||||
<span className="text-neutral-400">(审核中)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity text-pink-400 hover:text-pink-600 cursor-pointer"
|
||||
onClick={onToggleCommentForm}
|
||||
title="回复"
|
||||
>
|
||||
<RiReplyFill className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div className="mt-4 ml-16">
|
||||
<CommentForm
|
||||
parent={data.uuid}
|
||||
root={data.root || data.uuid}
|
||||
onSubmit={onReplySubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.children && data.children.length > 0 && (
|
||||
<div className="mt-4 ml-4 md:ml-16 pl-4 border-l-2 border-cyan-200">
|
||||
{data.children.map((item) => (
|
||||
<CommentItem key={item.uuid} data={item} onSubmit={onSubmit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
import { add } from "~/components/ui/notice/utils";
|
||||
import { useHydrated } from "~/hooks/use-hydrated";
|
||||
import { clsn } from "~/utils";
|
||||
import CommentForm, { type CommentFormSubmitValues } from "../comment-form";
|
||||
import CommentItem from "../comment-item";
|
||||
|
||||
const { APP_APIURL } = import.meta.env;
|
||||
|
||||
interface CommentsProps {
|
||||
className?: string;
|
||||
refs: string;
|
||||
}
|
||||
|
||||
export default function Comments({ className, refs }: CommentsProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const hydrated = useHydrated();
|
||||
const [list, setList] = useState<API.Comment.ICommentDetailData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const prevRefsRef = useRef<string>("");
|
||||
|
||||
const scrollToComment = (hash: string) => {
|
||||
const id = hash.replace(/^#/, "");
|
||||
|
||||
if (!id || !id.startsWith("c")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentItem = document.querySelector<HTMLDivElement>(`#${id}`);
|
||||
|
||||
if (!commentItem) {
|
||||
console.log("定位评论失败");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("定位评论成功");
|
||||
|
||||
commentItem.scrollIntoView();
|
||||
commentItem.classList.add("animate-scale");
|
||||
|
||||
const onAnimationEnd = (ev: AnimationEvent) => {
|
||||
const el = ev.target as HTMLDivElement;
|
||||
|
||||
if (el) {
|
||||
el.classList.remove("animate-scale");
|
||||
}
|
||||
|
||||
commentItem.removeEventListener("animationend", onAnimationEnd);
|
||||
};
|
||||
|
||||
commentItem.addEventListener("animationend", onAnimationEnd);
|
||||
};
|
||||
|
||||
const getCommentRequest = () => {
|
||||
fetch(`${APP_APIURL}/api/comment/detail/?ref=${encodeURIComponent(refs)}`)
|
||||
.then((res) => res.json())
|
||||
.then((res: API.PageResponse<API.Comment.ICommentDetailData[]>) => {
|
||||
if (res.status === "Success") {
|
||||
setList(res.data);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (location.hash.match(/^#c/)) {
|
||||
scrollToComment(location.hash);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !wrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (location.hash.match(/^#c/)) {
|
||||
wrapperRef.current.scrollIntoView();
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && prevRefsRef.current !== refs) {
|
||||
console.log("获取评论");
|
||||
prevRefsRef.current = refs;
|
||||
getCommentRequest();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(wrapperRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hydrated, refs, location.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated && location.hash.match(/^#c/) && wrapperRef.current) {
|
||||
scrollToComment(location.hash);
|
||||
}
|
||||
}, [hydrated, location.hash]);
|
||||
|
||||
const addCommentRequest = (data: CommentFormSubmitValues) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
fetch(`${APP_APIURL}/api/comment/add`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...data, ref: refs }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res: API.Response<API.Comment.ICommentDetailData>) => {
|
||||
if (res.status === "Success") {
|
||||
add({
|
||||
title: "评论成功",
|
||||
type: "success",
|
||||
content: res.msg,
|
||||
});
|
||||
|
||||
if (res.data) {
|
||||
if (res.data.root) {
|
||||
setList((prev) => {
|
||||
const index = prev.findIndex((item) => item.uuid === res.data.root);
|
||||
|
||||
if (index > -1) {
|
||||
const next = [...prev];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
children: [res.data, ...next[index].children],
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
else {
|
||||
setList((prev) => [{ ...res.data, children: [] }, ...prev]);
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
else {
|
||||
add({
|
||||
title: "评论失败",
|
||||
type: "error",
|
||||
content: res.msg,
|
||||
});
|
||||
|
||||
reject();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
reject();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={clsn("bg-white rounded-xl border-b-4 border-b-cyan-200 p-5 md:p-8", className)} id="comments">
|
||||
<h3 className="text-pink-400 text-2xl font-bold mb-6">
|
||||
评论
|
||||
</h3>
|
||||
<CommentForm onSubmit={addCommentRequest} />
|
||||
{list.length > 0 ? (
|
||||
<div>
|
||||
{list.map((item) => (
|
||||
<CommentItem key={item.uuid} data={item} onSubmit={addCommentRequest} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center opacity-50 py-8">
|
||||
{loading
|
||||
? "评论获取中"
|
||||
: list.length === 0
|
||||
? "快来成为第一个评论的人吧"
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,21 +66,27 @@ function Notice() {
|
||||
<div className="fixed top-24 right-0 z-2 mx-4 flex max-w-[30em] flex-col items-end">
|
||||
{items.map((item) => {
|
||||
const showProgress = !!item.duration && item.duration > 0;
|
||||
const theme = {
|
||||
default: { bg: "bg-pink-100", text: "text-pink-700", bar: "bg-pink-500" },
|
||||
success: { bg: "bg-cyan-100", text: "text-cyan-700", bar: "bg-cyan-500" },
|
||||
error: { bg: "bg-pink-100", text: "text-pink-700", bar: "bg-pink-500" },
|
||||
warning: { bg: "bg-yellow-100", text: "text-yellow-700", bar: "bg-yellow-500" },
|
||||
}[item.type || "default"];
|
||||
|
||||
return (
|
||||
<div key={item.key} className="relative overflow-hidden px-5 py-4 pr-14 mb-4 min-w-[20em] rounded-xl bg-pink-100 animate-fade-in-left">
|
||||
<div key={item.key} className={clsn("relative overflow-hidden px-5 py-4 pr-14 mb-4 min-w-[20em] rounded-xl animate-fade-in-left", theme.bg, theme.text)}>
|
||||
<button
|
||||
className="absolute text-xl font-semibold right-6 text-pink-700 cursor-pointer"
|
||||
className={clsn("absolute text-xl font-semibold right-6 cursor-pointer", theme.text)}
|
||||
onClick={() => onCloseWithKey(item.key as string)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h4 className="text-lg font-semibold text-pink-700">{item.title}</h4>
|
||||
<h4 className={clsn("text-lg font-semibold", theme.text)}>{item.title}</h4>
|
||||
<p className="text-sm mt-4">
|
||||
{item.content}
|
||||
</p>
|
||||
{showProgress && (
|
||||
<div className={clsn("absolute h-1 left-0 right-0 bottom-0 bg-pink-500", styles.progress)} style={{ animationDuration: `${(item.duration || 5000) / 1000}s` }}></div>
|
||||
<div className={clsn("absolute h-1 left-0 right-0 bottom-0", styles.progress, theme.bar)} style={{ animationDuration: `${(item.duration || 5000) / 1000}s` }}></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface NoticeItem {
|
||||
key?: string;
|
||||
title: string;
|
||||
duration?: number;
|
||||
type?: "success" | "error" | "warning";
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user