Feat: 新增语录页和通知提示组件

This commit is contained in:
奇趣保罗 2026-05-21 11:25:35 +08:00
parent 1c86b7faee
commit d585fc909f
12 changed files with 400 additions and 2 deletions

View File

@ -1,8 +1,9 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useState, useRef, useImperativeHandle, forwardRef, type Ref } from "react"; import React, { useState, useRef, useImperativeHandle, forwardRef, type Ref } from "react";
import { createPortal } from 'react-dom'; import { createPortal } from "react-dom";
import { clsn } from '~/utils'; import { X } from "~/components/ui/icons";
import { clsn } from "~/utils";
const parseMeta = (metaStr: string) => { const parseMeta = (metaStr: string) => {
try { try {
@ -151,6 +152,9 @@ function LightBox({ className, list }: LightBoxProps, ref: Ref<LightBoxInst>) {
onWheel={onScroll} onWheel={onScroll}
> >
<div className="flex-1 flex relative p-4" role="img" aria-label={title} onClick={onClose}> <div className="flex-1 flex relative p-4" role="img" aria-label={title} onClick={onClose}>
<button className="absolute top-4 left-4 z-2 p-2 rounded-xl text-xl font-semibold text-pink-700 bg-black/60 cursor-pointer" onClick={onClose}>
<X className="w-6 h-6" />
</button>
{isVideo ? ( {isVideo ? (
<video <video
src={src} src={src}

View File

@ -6,6 +6,7 @@ const navItems = [
{ name: "首页", to: "/" }, { name: "首页", to: "/" },
{ name: "日记", to: "/note" }, { name: "日记", to: "/note" },
{ name: "相册", to: "/gallery" }, { name: "相册", to: "/gallery" },
{ name: "语录", to: "/say" },
{ name: "关于我", to: "/about" }, { name: "关于我", to: "/about" },
]; ];

View File

@ -49,3 +49,11 @@ export const Steam = (props: IconProps) => (
export const Feed = (props: IconProps) => ( export const Feed = (props: IconProps) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3C12.9411 3 21 11.0589 21 21H18C18 12.7157 11.2843 6 3 6V3ZM3 10C9.07513 10 14 14.9249 14 21H11C11 16.5817 7.41828 13 3 13V10ZM3 17C5.20914 17 7 18.7909 7 21H3V17Z"></path></svg> <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3C12.9411 3 21 11.0589 21 21H18C18 12.7157 11.2843 6 3 6V3ZM3 10C9.07513 10 14 14.9249 14 21H11C11 16.5817 7.41828 13 3 13V10ZM3 17C5.20914 17 7 18.7909 7 21H3V17Z"></path></svg>
); );
export const Heart = (props: IconProps) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853Z"></path></svg>
);
export const X = (props: IconProps) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z"></path></svg>
);

View File

@ -0,0 +1,12 @@
.progress {
animation: progress 1s linear;
}
@keyframes progress {
from {
width: 100%;
}
to {
width: 0;
}
}

View File

@ -0,0 +1,92 @@
// Hooks
import { useEffect, useState } from "react";
// UI
import styles from "./Notice.module.css";
import { createPortal } from "react-dom";
// Tools
import { type NoticeItem, addFn, removeFn } from "./utils";
import { useHydrated } from "~/hooks/use-hydrated";
import { clsn } from "~/utils";
// Components
function Notice() {
const [items, setItems] = useState<NoticeItem[]>([]);
useEffect(() => {
const fn = (notice: NoticeItem) => {
const key = `${Math.ceil(performance.now())}-${Math.round(Math.random() * 100)}`;
setItems((prevItems) => [...prevItems, { ...notice, key }]);
if (notice.duration && notice.duration > 0) {
setTimeout(() => {
onCloseWithKey(key);
}, notice.duration);
}
};
addFn(fn);
return () => {
removeFn(fn);
};
}, []);
const onCloseWithKey = (key: string) => {
setItems((prevItems) => {
const index = prevItems.findIndex((item) => item.key === key);
const nextItems = [...prevItems];
if (index > -1) {
nextItems.splice(index, 1);
}
return nextItems;
});
}
const hydrated = useHydrated();
if (typeof document === "undefined") {
return null;
}
if (!hydrated) {
return null;
}
return createPortal((
<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;
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">
<button
className="absolute text-xl font-semibold right-6 text-pink-700 cursor-pointer"
onClick={() => onCloseWithKey(item.key as string)}
>
×
</button>
<h4 className="text-lg font-semibold text-pink-700">{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>
);
})}
</div>
), document.body);
}
export default Notice;

View File

@ -0,0 +1,33 @@
export interface NoticeItem {
key?: string;
title: string;
duration?: number;
content: React.ReactNode;
}
type NoticeFn = (notice: NoticeItem) => void;
const addNoticeFn: NoticeFn[] = [];
export const add = (notice: NoticeItem) => {
addNoticeFn.forEach((item) => {
// 如果没传递 duration默认设置为 5000
if (!("duration" in notice)) {
notice.duration = 5000;
}
item(notice);
});
}
export const addFn = (fn: NoticeFn) => {
return addNoticeFn.push(fn) - 1;
}
export const removeFn = (fn: NoticeFn) => {
const index = addNoticeFn.indexOf(fn);
if (index > -1) {
addNoticeFn.splice(index, 1);
}
}

View File

@ -0,0 +1,13 @@
import { useSyncExternalStore } from "react";
function subscribe() {
return () => {};
}
export function useHydrated() {
return useSyncExternalStore(
subscribe,
() => true,
() => false,
);
}

View File

@ -11,6 +11,7 @@ export default [
route("note", "routes/note.tsx"), route("note", "routes/note.tsx"),
route("note/:year/:id", "routes/note-detail.tsx"), route("note/:year/:id", "routes/note-detail.tsx"),
route("gallery/*", "routes/gallery.tsx"), route("gallery/*", "routes/gallery.tsx"),
route("say", "routes/say.tsx"),
route("*", "routes/page.tsx"), route("*", "routes/page.tsx"),
]), ]),
] satisfies RouteConfig; ] satisfies RouteConfig;

View File

@ -3,6 +3,7 @@ import { Outlet } from "react-router";
import Footer from "~/components/layout/footer"; import Footer from "~/components/layout/footer";
import Header from "~/components/layout/header"; import Header from "~/components/layout/header";
import Spinner from "~/components/layout/spinner"; import Spinner from "~/components/layout/spinner";
import Notice from "~/components/ui/notice";
export default function AppLayout() { export default function AppLayout() {
return ( return (
@ -11,6 +12,7 @@ export default function AppLayout() {
<Header /> <Header />
<Outlet /> <Outlet />
<Footer /> <Footer />
<Notice />
</> </>
); );
} }

47
app/routes/say.module.css Normal file
View File

@ -0,0 +1,47 @@
.item {
&:nth-child(2n) {
border-bottom-color: #fde047;
}
&:nth-child(3n) {
border-bottom-color: #84cc16;
}
&:nth-child(4n) {
border-bottom-color: #2dd4bf;
}
&:nth-child(5n) {
border-bottom-color: #fb923c;
}
&:nth-child(6n) {
border-bottom-color: #a78bfa;
}
}
.like .active::after {
content: "♥";
position: absolute;
left: 0;
top: 0;
color: #f87171;
animation: like-float 1s forwards;
}
@keyframes like-float {
0% {
opacity: 0;
transform: translateY(0);
}
50% {
opacity: 1;
transform: translateY(-1.5em);
}
100% {
opacity: 0;
transform: translateY(-1.5em);
}
}

168
app/routes/say.tsx Normal file
View File

@ -0,0 +1,168 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { ThumbUpFill } from "~/components/ui/icons";
import Pagination from "~/components/ui/pagination";
import { add } from "~/components/ui/notice/utils";
import { clsn, siteTitle } from "~/utils";
import type { Route } from "./+types/say";
import styles from "./say.module.css";
const PAGE_SIZE = 20;
const DESCRIPTION = "奇趣保罗的个人语录,以及一些高赞评论";
function formatAuthor(item: API.Say.ISayData) {
if (item.origin && item.author) {
return `出自 “${item.origin}”,作者:${item.author}`;
}
if (item.origin) {
return `出自 “${item.origin}`;
}
if (item.author) {
return `作者:${item.author}`;
}
return "";
}
export function meta({ loaderData }: Route.MetaArgs) {
const title = siteTitle("语录");
return [
{ title },
{ name: "description", content: DESCRIPTION },
{ property: "og:title", content: title },
{ property: "og:description", content: DESCRIPTION },
{ property: "og:type", content: "website" },
{ property: "og:url", content: loaderData?.canonical },
{ tagName: "link", rel: "canonical", href: loaderData?.canonical },
];
}
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "1";
const say = (await fetch(`https://paul.ren/api/say/page?page=${page}`).then((res) =>
res.json()
)) as API.PageResponse<API.Say.ISayData[]>;
const canonical = new URL(url);
if (page === "1") {
canonical.searchParams.delete("page");
}
return { say, page, canonical: canonical.href };
}
export default function Say({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const { say, page } = loaderData;
const [items, setItems] = useState(say.data);
useEffect(() => {
setItems(say.data);
}, [say.data]);
useEffect(() => {
window.scrollTo({ top: 0 });
}, [page]);
const onChangePage = (value: number) => {
navigate({
search: value > 1 ? `?page=${value}` : "",
});
};
const onLike = async (target: HTMLButtonElement, id: number, index: number) => {
target.classList.add(styles.likeActive);
setItems((prevItems) => {
const nextItems = [...prevItems];
nextItems[index] = {
...nextItems[index],
likes: nextItems[index].likes + 1,
};
return nextItems;
});
const formData = new FormData();
formData.append("id", String(id));
const res = (await fetch("https://paul.ren/api/say/like", {
method: "POST",
body: formData,
}).then((response) => response.json())) as API.Response<null>;
if (res.status === "Success") {
add({
title: "点赞成功",
content: res.msg,
});
} else {
add({
title: "点赞失败",
content: res.msg,
});
setItems((prevItems) => {
const nextItems = [...prevItems];
nextItems[index] = {
...nextItems[index],
likes: nextItems[index].likes - 1,
};
return nextItems;
});
}
};
return (
<main className="px-2 py-24 max-w-4xl mx-auto">
<section className="mb-12">
<h1 className="text-center text-5xl/tight md:text-7xl/tight"></h1>
</section>
<section className="columns-1 md:columns-2 mb-12">
{items.map((item, index) => {
const author = formatAuthor(item);
return (
<blockquote
key={item.id}
className={clsn(
styles.item,
"group p-5 bg-white rounded-xl border-4 border-transparent border-b-cyan-200 border-l-4 transition-colors whitespace-pre-wrap",
"break-inside-avoid mb-4",
)}
>
<p className="mb-4 leading-relaxed">{item.content}</p>
<p className="flex items-center gap-3 text-sm italic opacity-60">
<button
type="button"
className={clsn(styles.like, "opacity-0 group-hover:opacity-60 transition-opacity relative inline-flex items-center not-italic cursor-pointer mr-auto")}
onClick={(ev) => onLike(ev.currentTarget, item.id, index)}
>
<ThumbUpFill className="h-4 w-4 mr-1" />
{item.likes}
</button>
{author && <span>{author}</span>}
</p>
</blockquote>
);
})}
</section>
<section className="text-center">
<Pagination
current={Number(page)}
size={PAGE_SIZE}
total={say.count}
onClick={onChangePage}
/>
</section>
</main>
);
}

17
app/types/api.say.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
declare namespace API {
namespace Say {
export interface ISayData {
id: number
author: string
content: string
origin: string
link: string
is_comment: boolean
likes: number
created_at: string | null
updated_at: string | null
}
}
}