feat(ui): new toast component

This commit is contained in:
Ou 2024-10-23 02:43:38 +08:00
parent 12732dc0c2
commit 2edfb06a61
8 changed files with 193 additions and 36 deletions

View File

@ -2,7 +2,7 @@ import { atom } from "jotai"
import type { ColumnID, SourceID } from "@shared/types"
import { sources } from "@shared/sources"
import { primitiveMetadataAtom } from "./primitiveMetadataAtom"
import type { Update } from "./types"
import type { ToastItem, Update } from "./types"
export { primitiveMetadataAtom, preprocessMetadata } from "./primitiveMetadataAtom"
@ -56,3 +56,5 @@ export const goToTopAtom = atom({
ok: false,
fn: undefined as (() => void) | undefined,
})
export const toastAtom = atom<ToastItem[]>([])

View File

@ -1 +1,15 @@
import type { MaybePromise } from "@shared/type.util"
export type Update<T> = T | ((prev: T) => T)
export interface ToastItem {
id: number
type?: "success" | "error" | "warning" | "info"
msg: string
duration?: number
action?: {
label: string
onClick: () => MaybePromise<void>
}
onDismiss?: () => MaybePromise<void>
}

View File

@ -1,35 +1,125 @@
import clsx from "clsx"
import { Toaster } from "sonner"
import { AnimatePresence, motion } from "framer-motion"
import { useAtomValue, useSetAtom } from "jotai"
import { useCallback, useMemo, useRef } from "react"
import { useHoverDirty, useMount, useUpdateEffect, useWindowSize } from "react-use"
import { toastAtom } from "~/atoms"
import type { ToastItem } from "~/atoms/types"
import { Timer } from "~/utils"
const WIDTH = 320
export function Toast() {
const { width } = useWindowSize()
const center = useMemo(() => {
const t = (width - WIDTH) / 2
return t > width * 0.9 ? width * 0.9 : t
}, [width])
const toastItems = useAtomValue(toastAtom)
return (
<Toaster
toastOptions={{
duration: 5000,
unstyled: true,
classNames: {
toast: clsx(
"flex gap-1 p-1 rounded-xl backdrop-blur-5 items-center bg-op-70! w-full",
"bg-blue",
"data-[type=error]:(bg-red)",
"data-[type=success]:(bg-green)",
"data-[type=info]:(bg-blue)",
"data-[type=warning]:(bg-yellow)",
),
icon: "text-white ml-1 dark:text-dark-600 text-op-80!",
content: "bg-base bg-op-70! p-2 rounded-lg color-base w-full backdrop-blur-xl",
title: "font-normal text-base",
description: "color-base text-op-80! text-sm",
actionButton: "bg-base bg-op-70! rounded-lg py-2 w-4em backdrop-blur-lg hover:(bg-base bg-op-60!)",
closeButton: "bg-base bg-op-50! border-0 hover:(bg-base bg-op-70!)",
<AnimatePresence>
{toastItems.length && (
<motion.ol
initial="hidden"
animate="visible"
style={{
width: WIDTH,
left: center,
}}
variants={{
visible: {
transition: {
delayChildren: 0.1,
staggerChildren: 0.2,
},
},
}}
closeButton
expand
style={{
top: 10,
}}
position="top-center"
/>
className="absolute top-0 z-99 flex flex-col gap-2"
>
{
toastItems.map(k => <Item key={k.id} info={k} />)
}
</motion.ol>
)}
</AnimatePresence>
)
}
const colors = {
success: "green",
error: "red",
warning: "orange",
info: "blue",
}
function Item({ info }: { info: ToastItem }) {
const color = colors[info.type ?? "info"]
const setToastItems = useSetAtom(toastAtom)
const hidden = useCallback((dismiss = true) => {
setToastItems(prev => prev.filter(k => k.id !== info.id))
if (dismiss) {
info.onDismiss?.()
}
}, [info, setToastItems])
const timer = useRef<Timer>()
useMount(() => {
timer.current = new Timer(() => {
hidden()
}, info.duration ?? 5000)
return () => timer.current?.clear()
})
const ref = useRef(null)
const isHoverd = useHoverDirty(ref)
useUpdateEffect(() => {
if (isHoverd) {
timer.current?.pause()
} else {
timer.current?.resume()
}
}, [isHoverd])
return (
<motion.li
ref={ref}
layout
variants={{
hidden: { y: 0, opacity: 0 },
visible: {
y: 15,
opacity: 1,
},
}}
className={clsx(
"bg-base rounded-lg shadow-xl relative",
)}
>
<div className={clsx(
`bg-${color}-500 dark:bg-${color} bg-op-40! p2 backdrop-blur-5 rounded-lg w-full`,
"flex items-center gap-2",
)}
>
{
isHoverd
? <button type="button" className={`i-ph:x-circle color-${color}-500 i-ph:info`} onClick={() => hidden(false)} />
: <span className={`i-ph:info color-${color}-500 `} />
}
<div className="flex justify-between w-full">
<span className="op-90 dark:op-100">
{info.msg}
</span>
{info.action && (
<button
type="button"
className={`text-sm color-${color}-500 bg-base op-80 bg-op-50! px-1 rounded min-w-10 hover:bg-op-70!`}
onClick={info.action.onClick}
>
{info.action.label}
</button>
)}
</div>
</div>
</motion.li>
)
}

View File

@ -1,16 +1,17 @@
import { useEffect } from "react"
import { toast } from "sonner"
import { useRegisterSW } from "virtual:pwa-register/react"
import { useToast } from "./useToast"
export function usePWA() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW()
const toaster = useToast()
useEffect(() => {
if (needRefresh) {
toast("网站有更新,点击更新", {
toaster("网站有更新,点击更新", {
action: {
label: "更新",
onClick: () => updateServiceWorker(true),
@ -20,5 +21,5 @@ export function usePWA() {
},
})
}
}, [needRefresh, updateServiceWorker, setNeedRefresh])
}, [needRefresh, updateServiceWorker, setNeedRefresh, toaster])
}

View File

@ -2,8 +2,8 @@ import type { PrimitiveMetadata } from "@shared/types"
import { useAtom } from "jotai"
import { ofetch } from "ofetch"
import { useDebounce, useMount } from "react-use"
import { toast } from "sonner"
import { useLogin } from "./useLogin"
import { useToast } from "./useToast"
import { preprocessMetadata, primitiveMetadataAtom } from "~/atoms"
import { safeParseString } from "~/utils"
@ -49,6 +49,7 @@ export async function downloadMetadata(): Promise<PrimitiveMetadata | undefined>
export function useSync() {
const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom)
const { logout, login } = useLogin()
const toaster = useToast()
useDebounce(async () => {
if (primitiveMetadata.action === "manual") {
@ -64,7 +65,8 @@ export function useSync() {
}
} catch (e: any) {
if (e.statusCode === 401) {
toast.error("身份校验失败,请重新登录", {
toaster("身份校验失败,请重新登录", {
type: "error",
action: {
label: "登录",
onClick: login,

18
src/hooks/useToast.ts Normal file
View File

@ -0,0 +1,18 @@
import { useSetAtom } from "jotai"
import { useCallback } from "react"
import { toastAtom } from "~/atoms"
import type { ToastItem } from "~/atoms/types"
export function useToast() {
const setToastItems = useSetAtom(toastAtom)
return useCallback((msg: string, props?: Omit<ToastItem, "id" | "msg">) => {
setToastItems(prev => [
{
msg,
id: Date.now(),
...props,
},
...prev,
])
}, [setToastItems])
}

View File

@ -1,3 +1,5 @@
import type { MaybePromise } from "@shared/type.util"
export function safeParseString(str: any) {
try {
return JSON.parse(str)
@ -5,3 +7,31 @@ export function safeParseString(str: any) {
return ""
}
}
export class Timer {
private timerId?: any
private start!: number
private remaining: number
private callback: () => MaybePromise<void>
constructor(callback: () => MaybePromise<void>, delay: number) {
this.callback = callback
this.remaining = delay
this.resume()
}
pause() {
clearTimeout(this.timerId)
this.remaining -= Date.now() - this.start
}
resume() {
this.start = Date.now()
clearTimeout(this.timerId)
this.timerId = setTimeout(this.callback, this.remaining)
}
clear() {
clearTimeout(this.timerId)
}
}

View File

@ -36,7 +36,7 @@ export default defineConfig({
"btn": "op50 hover:op85",
},
safelist: [
...[...new Set(Object.values(sources).map(k => k.color))].map(k =>
...["orange", ...new Set(Object.values(sources).map(k => k.color))].map(k =>
`bg-${k} color-${k} border-${k} sprinkle-${k} shadow-${k}
bg-${k}-500 color-${k}-500
dark:bg-${k} dark:color-${k}`.trim().split(/\s+/)).flat(),