mirror of
https://github.com/ourongxing/newsnow.git
synced 2025-01-18 18:59:15 +08:00
feat(ui): new toast component
This commit is contained in:
parent
12732dc0c2
commit
2edfb06a61
@ -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[]>([])
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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])
|
||||
}
|
||||
|
@ -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
18
src/hooks/useToast.ts
Normal 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])
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
Loading…
x
Reference in New Issue
Block a user