Feat: 新增评论组件,细化通知组件样式,引入环境变量,替换图标组件

This commit is contained in:
2026-07-30 00:59:09 +08:00
parent d585fc909f
commit b66de3129d
18 changed files with 630 additions and 14 deletions
+16
View File
@@ -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) {
+4 -2
View File
@@ -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>
);
}
+188
View File
@@ -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>
);
}
+10 -4
View File
@@ -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>
);
+1
View File
@@ -2,6 +2,7 @@ export interface NoticeItem {
key?: string;
title: string;
duration?: number;
type?: "success" | "error" | "warning";
content: React.ReactNode;
}
+2
View File
@@ -0,0 +1,2 @@
// 调试期间固定返回 true,后续需要接入真实的登录态判断
export const useIsLogin = () => false;
+4 -2
View File
@@ -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<API.Gallery.ICateData[]>;
const category = await fetch(`${APP_APIURL}/api/gallery`).then((res) => res.json()) as unknown as API.PageResponse<API.Gallery.ICateData[]>;
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<API.Media.IMediaData[]>;
const media = await fetch(`${APP_APIURL}/api/media/?${searchParams.toString()}`).then((res) => res.json()) as unknown as API.PageResponse<API.Media.IMediaData[]>;
const currentCategory = cateIndex > -1 ? category.data[cateIndex]: undefined;
return { media, category, currentCategory, page };
+3 -1
View File
@@ -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<API.Sync.ISyncData>;
+56 -2
View File
@@ -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<API.Note.INoteData>;
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) {
</section>
<section className="p-5 md:p-12 bg-white rounded-xl border-b-4 border-b-cyan-200 mb-16">
<Article html={note.data.content_html} />
{hasMedia && (
<div className="mt-8 flex flex-col gap-4">
{mediaList.map((media, index) => {
const isVideo = media.type === "mp4" || media.url.includes("mp4");
if (isVideo) {
return (
<video
key={media.id || media.url}
src={media.url}
controls
className="w-full rounded-xl cursor-pointer"
onClick={() => onShowMediaLightBox(index)}
/>
);
}
return (
<img
key={media.id || media.url}
src={getImageThumbUrl(media.url)}
alt={media.title}
loading="lazy"
className="w-full rounded-xl cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => onShowMediaLightBox(index)}
/>
);
})}
</div>
)}
</section>
<section className="flex gap-16 items-center justify-center text-base md:text-lg">
<section className="flex gap-16 items-center justify-center text-base md:text-lg mb-16">
<button onClick={onLike}>
<ThumbUpFill className="inline-block h-8 w-8 mr-2" />
{likes}
@@ -80,6 +130,10 @@ export default function NoteDetail({ loaderData }: Route.ComponentProps) {
打赏
</button>
</section>
<Comments refs={commentRef} />
{hydrated && <LightBox ref={mediaLightBoxRef} list={mediaList} />}
</main>
);
}
+3 -1
View File
@@ -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<API.Note.INoteData[]>;
const note = await fetch(`${APP_APIURL}/api/note/?page=${page}&year=${year}`).then((res) => res.json()) as API.PageResponse<API.Note.INoteData[]>;
return { note, page, year };
}
+3 -1
View File
@@ -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<API.Page.IPageData>;
if (page.status === "Failed") {
+43
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ declare namespace API {
starred: boolean;
unlocked?: boolean;
media: any[];
media: API.Media.IMediaData[];
photo?: INotePhotoData[];
year?: string;
+27
View File
@@ -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<string, string>) => {
if (typeof window === "undefined") {
return;
}
localStorage.setItem("paul-author", values.name);
localStorage.setItem("paul-email", values.email);
localStorage.setItem("paul-site", values.site);
};
+1
View File
@@ -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",
+12
View File
@@ -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