diff --git a/app/app.css b/app/app.css index adb54cd..f3aa470 100644 --- a/app/app.css +++ b/app/app.css @@ -8,6 +8,7 @@ --animate-fade-out: fade-out 0.3s both; --animate-fade-in-left: fade-in-left 0.3s backwards; --animate-fade-off-right: fade-off-right 0.3s forwards; + --animate-scale: scale 0.3s 1s; @keyframes spinner-bar { 0% { @@ -57,6 +58,21 @@ transform: translateX(1.5rem); } } + + @keyframes scale { + 0% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.1); + } + 100% { + opacity: 1; + transform: scale(1); + } + } } @media screen and (max-width: 639px) { diff --git a/app/components/layout/header.tsx b/app/components/layout/header.tsx index 0dab02f..a88554d 100644 --- a/app/components/layout/header.tsx +++ b/app/components/layout/header.tsx @@ -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() { ))} - + - + diff --git a/app/components/shared/comment-form/index.tsx b/app/components/shared/comment-form/index.tsx new file mode 100644 index 0000000..48349ce --- /dev/null +++ b/app/components/shared/comment-form/index.tsx @@ -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; +} + +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) => { + 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 ( + + + 欢迎回来! + + + + + + {loading && } + 提交 + + + ); + } + + return ( + + + + + 昵称 + + + + 邮箱 + + + + 站点 + + + + + + 评论 + + + + {loading && } + 提交 + + + + + ); +} diff --git a/app/components/shared/comment-item/index.tsx b/app/components/shared/comment-item/index.tsx new file mode 100644 index 0000000..ced5c10 --- /dev/null +++ b/app/components/shared/comment-item/index.tsx @@ -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; +} + +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 ( + + + + + + {data.name} + {data.is_ai_bot && ( + + + + )} + + + {data.content} + {data.status === "waiting" && ( + (审核中) + )} + + + + + + + + {active && ( + + + + )} + + {data.children && data.children.length > 0 && ( + + {data.children.map((item) => ( + + ))} + + )} + + ); +} diff --git a/app/components/shared/comment/index.tsx b/app/components/shared/comment/index.tsx new file mode 100644 index 0000000..f988422 --- /dev/null +++ b/app/components/shared/comment/index.tsx @@ -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(null); + const location = useLocation(); + const hydrated = useHydrated(); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const prevRefsRef = useRef(""); + + const scrollToComment = (hash: string) => { + const id = hash.replace(/^#/, ""); + + if (!id || !id.startsWith("c")) { + return; + } + + const commentItem = document.querySelector(`#${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) => { + 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((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) => { + 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 ( + + + 评论 + + + {list.length > 0 ? ( + + {list.map((item) => ( + + ))} + + ) : ( + + {loading + ? "评论获取中" + : list.length === 0 + ? "快来成为第一个评论的人吧" + : null} + + )} + + ); +} diff --git a/app/components/ui/notice/index.tsx b/app/components/ui/notice/index.tsx index 8a14106..80bf19c 100644 --- a/app/components/ui/notice/index.tsx +++ b/app/components/ui/notice/index.tsx @@ -66,21 +66,27 @@ function Notice() { {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 ( - + onCloseWithKey(item.key as string)} > × - {item.title} + {item.title} {item.content} {showProgress && ( - + )} ); diff --git a/app/components/ui/notice/utils.ts b/app/components/ui/notice/utils.ts index 51cce29..65a1927 100644 --- a/app/components/ui/notice/utils.ts +++ b/app/components/ui/notice/utils.ts @@ -2,6 +2,7 @@ export interface NoticeItem { key?: string; title: string; duration?: number; + type?: "success" | "error" | "warning"; content: React.ReactNode; } diff --git a/app/composables/use-is-login.ts b/app/composables/use-is-login.ts new file mode 100644 index 0000000..0711d66 --- /dev/null +++ b/app/composables/use-is-login.ts @@ -0,0 +1,2 @@ +// 调试期间固定返回 true,后续需要接入真实的登录态判断 +export const useIsLogin = () => false; diff --git a/app/routes/gallery.tsx b/app/routes/gallery.tsx index 8dda5d6..dbc3aed 100644 --- a/app/routes/gallery.tsx +++ b/app/routes/gallery.tsx @@ -9,6 +9,8 @@ import { getImageThumbUrl } from "~/utils/media"; import type { Route } from "./+types/gallery"; import styles from "./gallery.module.css"; +const { APP_APIURL } = import.meta.env; + export function meta({ loaderData }: Route.MetaArgs) { return [ { title: siteTitle(loaderData?.currentCategory?.name || "相册") }, @@ -28,7 +30,7 @@ export async function loader({ request, params }: Route.LoaderArgs) { const searchParams = new URLSearchParams(); searchParams.append("page", page); - const category = await fetch(`https://paul.ren/api/gallery`).then((res) => res.json()) as unknown as API.PageResponse; + const category = await fetch(`${APP_APIURL}/api/gallery`).then((res) => res.json()) as unknown as API.PageResponse; let cateIndex = -1; if (params["*"]) { @@ -41,7 +43,7 @@ export async function loader({ request, params }: Route.LoaderArgs) { searchParams.append("cate", String(category.data[cateIndex].id)); } - const media = await fetch(`https://paul.ren/api/media/?${searchParams.toString()}`).then((res) => res.json()) as unknown as API.PageResponse; + const media = await fetch(`${APP_APIURL}/api/media/?${searchParams.toString()}`).then((res) => res.json()) as unknown as API.PageResponse; const currentCategory = cateIndex > -1 ? category.data[cateIndex]: undefined; return { media, category, currentCategory, page }; diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 1c2d085..cd8dc78 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -17,6 +17,8 @@ import type { Route } from "./+types/index"; import styles from "./index.module.css"; +const { APP_APIURL } = import.meta.env; + const year = new Date().getFullYear(); export function meta({}: Route.MetaArgs) { @@ -27,7 +29,7 @@ export function meta({}: Route.MetaArgs) { } export async function loader() { - const data = (await fetch("https://paul.ren/api/sync").then((res) => + const data = (await fetch(`${APP_APIURL}/api/sync`).then((res) => res.json() )) as unknown as API.Response; diff --git a/app/routes/note-detail.tsx b/app/routes/note-detail.tsx index 5954f80..085e121 100644 --- a/app/routes/note-detail.tsx +++ b/app/routes/note-detail.tsx @@ -1,11 +1,18 @@ import { useState } from "react"; +import { useParams } from "react-router"; import Article from "~/components/common/article"; +import LightBox, { useLightBox } from "~/components/biz/gallery/image-box"; +import Comments from "~/components/shared/comment"; import { CupFill, ShareFill, ThumbUpFill } from "~/components/ui/icons"; +import { useHydrated } from "~/hooks/use-hydrated"; import { siteTitle } from "~/utils"; +import { getImageThumbUrl } from "~/utils/media"; import type { Route } from "./+types/note-detail"; +const { APP_APIURL } = import.meta.env; + export function meta({ loaderData }: Route.MetaArgs) { return [ { title: loaderData ? siteTitle(loaderData.data.title) : "404" }, @@ -19,7 +26,7 @@ export async function loader({ params }: Route.LoaderArgs) { } const note = (await fetch( - `https://paul.ren/api/note/get?id=${params.id}&year=${params.year}` + `${APP_APIURL}/api/note/get?id=${params.id}&year=${params.year}`, ).then((res) => res.json())) as API.Response; if (note.status === "Failed") { @@ -31,9 +38,22 @@ export async function loader({ params }: Route.LoaderArgs) { export default function NoteDetail({ loaderData }: Route.ComponentProps) { const note = loaderData; + const params = useParams(); + const hydrated = useHydrated(); + const { ref: mediaLightBoxRef, open: onShowMediaLightBox } = useLightBox(); const [likes, setLikes] = useState(note.data.likes); + const mediaList = note.data.media?.length + ? note.data.media.map((item) => ({ + ...item, + thumb_url: item.thumb_url || getImageThumbUrl(item.url), + })) + : []; + + const hasMedia = mediaList.length > 0; + const commentRef = `note-${params.year}-${params.id}`; + const onLike = () => { setLikes((prevLike) => prevLike + 1); }; @@ -65,8 +85,38 @@ export default function NoteDetail({ loaderData }: Route.ComponentProps) { + {hasMedia && ( + + {mediaList.map((media, index) => { + const isVideo = media.type === "mp4" || media.url.includes("mp4"); + + if (isVideo) { + return ( + onShowMediaLightBox(index)} + /> + ); + } + + return ( + onShowMediaLightBox(index)} + /> + ); + })} + + )} - + {likes} @@ -80,6 +130,10 @@ export default function NoteDetail({ loaderData }: Route.ComponentProps) { 打赏 + + + + {hydrated && } ); } diff --git a/app/routes/note.tsx b/app/routes/note.tsx index 36448c6..4e77b22 100644 --- a/app/routes/note.tsx +++ b/app/routes/note.tsx @@ -8,6 +8,8 @@ import { getFirstImage, years } from "~/utils/note"; import type { Route } from "./+types/note"; +const { APP_APIURL } = import.meta.env; + export function meta({}: Route.MetaArgs) { return [ { title: siteTitle("日记") }, @@ -20,7 +22,7 @@ export async function loader({ request }: Route.LoaderArgs) { const year = url.searchParams.get("year") || new Date().getFullYear(); const page = url.searchParams.get("page") || 1; - const note = await fetch(`https://paul.ren/api/note/?page=${page}&year=${year}`).then((res) => res.json()) as API.PageResponse; + const note = await fetch(`${APP_APIURL}/api/note/?page=${page}&year=${year}`).then((res) => res.json()) as API.PageResponse; return { note, page, year }; } diff --git a/app/routes/page.tsx b/app/routes/page.tsx index e6e15f5..f000387 100644 --- a/app/routes/page.tsx +++ b/app/routes/page.tsx @@ -3,6 +3,8 @@ import { siteTitle } from "~/utils"; import type { Route } from "./+types/page"; +const { APP_APIURL } = import.meta.env; + export function meta({ loaderData }: Route.MetaArgs) { return [ { title: loaderData ? siteTitle(loaderData.data.title) : "404" }, @@ -14,7 +16,7 @@ export async function loader({ params }: Route.LoaderArgs) { const slug = params["*"]; const page = (await fetch( - `https://paul.ren/api/page/get?slug=${slug}&html`, + `${APP_APIURL}/api/page/get?slug=${slug}&html`, ).then((res) => res.json())) as API.Response; if (page.status === "Failed") { diff --git a/app/types/api.comment.d.ts b/app/types/api.comment.d.ts new file mode 100644 index 0000000..b30f0c3 --- /dev/null +++ b/app/types/api.comment.d.ts @@ -0,0 +1,43 @@ +declare namespace API { + namespace Comment { + type CommentStatus = "normal" | "waiting" | "rejected"; + + interface ICommentData { + id: number; + uuid: string; + + uid: number | null; + name: string | null; + email: string | null; + site: string | null; + avatar: string; + open_id: string | null; + + ref: string; + status: CommentStatus; + content: string; + parent: string | null; + // root 后端会自动推算,根据它 parent 的 root 字段填充 + root: string | null; + ip: string; + ua: string; + location: string | null; + is_private: boolean; + is_ai_bot: boolean; + + created_at: string | null; + updated_at: string | null; + } + + interface ICommentDetailData extends ICommentData { + children: ICommentDetailData[]; + } + + interface ICommentRankData { + name: string; + avatar: string; + site: string | null; + total: number; + } + } +} diff --git a/app/types/api.note.d.ts b/app/types/api.note.d.ts index 345e66e..797a4c3 100644 --- a/app/types/api.note.d.ts +++ b/app/types/api.note.d.ts @@ -53,7 +53,7 @@ declare namespace API { starred: boolean; unlocked?: boolean; - media: any[]; + media: API.Media.IMediaData[]; photo?: INotePhotoData[]; year?: string; diff --git a/app/utils/comment.ts b/app/utils/comment.ts new file mode 100644 index 0000000..386c476 --- /dev/null +++ b/app/utils/comment.ts @@ -0,0 +1,27 @@ +// 获取记录的评论用户信息 +export const getAuthorInfo = () => { + if (typeof window === "undefined") { + return { + name: "", + email: "", + site: "", + }; + } + + return { + name: localStorage.getItem("paul-author") || "", + email: localStorage.getItem("paul-email") || "", + site: localStorage.getItem("paul-site") || "", + }; +}; + +// 保存 +export const setAuthorInfo = (values: Record) => { + if (typeof window === "undefined") { + return; + } + + localStorage.setItem("paul-author", values.name); + localStorage.setItem("paul-email", values.email); + localStorage.setItem("paul-site", values.site); +}; diff --git a/package.json b/package.json index 04dafe6..71aec4e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@react-router/node": "7.15.1", "@react-router/serve": "7.15.1", + "@remixicon/react": "^4.9.0", "isbot": "^5.1.36", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32b2e4..7a20f98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@react-router/serve': specifier: 7.15.1 version: 7.15.1(react-router@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) + '@remixicon/react': + specifier: ^4.9.0 + version: 4.9.0(react@19.2.6) isbot: specifier: ^5.1.36 version: 5.1.40 @@ -431,6 +434,11 @@ packages: '@remix-run/node-fetch-server@0.13.1': resolution: {integrity: sha512-dOL+A/C84EA47gO/ps52KGrVSiYy96512rwtbXmJfWKYFm1FbrbjA3jao1hcIfao+jwVNEaZ1kTMwFjiino+HQ==} + '@remixicon/react@4.9.0': + resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==} + peerDependencies: + react: '>=18.2.0' + '@rolldown/binding-android-arm64@1.0.1': resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1917,6 +1925,10 @@ snapshots: '@remix-run/node-fetch-server@0.13.1': {} + '@remixicon/react@4.9.0(react@19.2.6)': + dependencies: + react: 19.2.6 + '@rolldown/binding-android-arm64@1.0.1': optional: true
+ {data.content} + {data.status === "waiting" && ( + (审核中) + )} +
{item.content}
+ 评论 +
+