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/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}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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", "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;
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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