Feat: 新增语录页和通知提示组件
This commit is contained in:
parent
1c86b7faee
commit
d585fc909f
|
|
@ -1,8 +1,9 @@
|
|||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import React, { useState, useRef, useImperativeHandle, forwardRef, type Ref } from "react";
|
||||
import { createPortal } from 'react-dom';
|
||||
import { clsn } from '~/utils';
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "~/components/ui/icons";
|
||||
import { clsn } from "~/utils";
|
||||
|
||||
const parseMeta = (metaStr: string) => {
|
||||
try {
|
||||
|
|
@ -151,6 +152,9 @@ function LightBox({ className, list }: LightBoxProps, ref: Ref<LightBoxInst>) {
|
|||
onWheel={onScroll}
|
||||
>
|
||||
<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 ? (
|
||||
<video
|
||||
src={src}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const navItems = [
|
|||
{ name: "首页", to: "/" },
|
||||
{ name: "日记", to: "/note" },
|
||||
{ name: "相册", to: "/gallery" },
|
||||
{ name: "语录", to: "/say" },
|
||||
{ name: "关于我", to: "/about" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -49,3 +49,11 @@ export const Steam = (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>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
.progress {
|
||||
animation: progress 1s linear;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribe() {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function useHydrated() {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export default [
|
|||
route("note", "routes/note.tsx"),
|
||||
route("note/:year/:id", "routes/note-detail.tsx"),
|
||||
route("gallery/*", "routes/gallery.tsx"),
|
||||
route("say", "routes/say.tsx"),
|
||||
route("*", "routes/page.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Outlet } from "react-router";
|
|||
import Footer from "~/components/layout/footer";
|
||||
import Header from "~/components/layout/header";
|
||||
import Spinner from "~/components/layout/spinner";
|
||||
import Notice from "~/components/ui/notice";
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
|
|
@ -11,6 +12,7 @@ export default function AppLayout() {
|
|||
<Header />
|
||||
<Outlet />
|
||||
<Footer />
|
||||
<Notice />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue