mirror of
https://github.com/ourongxing/newsnow.git
synced 2025-01-19 03:09:14 +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 type { ColumnID, SourceID } from "@shared/types"
|
||||||
import { sources } from "@shared/sources"
|
import { sources } from "@shared/sources"
|
||||||
import { primitiveMetadataAtom } from "./primitiveMetadataAtom"
|
import { primitiveMetadataAtom } from "./primitiveMetadataAtom"
|
||||||
import type { Update } from "./types"
|
import type { ToastItem, Update } from "./types"
|
||||||
|
|
||||||
export { primitiveMetadataAtom, preprocessMetadata } from "./primitiveMetadataAtom"
|
export { primitiveMetadataAtom, preprocessMetadata } from "./primitiveMetadataAtom"
|
||||||
|
|
||||||
@ -56,3 +56,5 @@ export const goToTopAtom = atom({
|
|||||||
ok: false,
|
ok: false,
|
||||||
fn: undefined as (() => void) | undefined,
|
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 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 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() {
|
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 (
|
return (
|
||||||
<Toaster
|
<AnimatePresence>
|
||||||
toastOptions={{
|
{toastItems.length && (
|
||||||
duration: 5000,
|
<motion.ol
|
||||||
unstyled: true,
|
initial="hidden"
|
||||||
classNames: {
|
animate="visible"
|
||||||
toast: clsx(
|
style={{
|
||||||
"flex gap-1 p-1 rounded-xl backdrop-blur-5 items-center bg-op-70! w-full",
|
width: WIDTH,
|
||||||
"bg-blue",
|
left: center,
|
||||||
"data-[type=error]:(bg-red)",
|
}}
|
||||||
"data-[type=success]:(bg-green)",
|
variants={{
|
||||||
"data-[type=info]:(bg-blue)",
|
visible: {
|
||||||
"data-[type=warning]:(bg-yellow)",
|
transition: {
|
||||||
),
|
delayChildren: 0.1,
|
||||||
icon: "text-white ml-1 dark:text-dark-600 text-op-80!",
|
staggerChildren: 0.2,
|
||||||
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!)",
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
closeButton
|
className="absolute top-0 z-99 flex flex-col gap-2"
|
||||||
expand
|
>
|
||||||
style={{
|
{
|
||||||
top: 10,
|
toastItems.map(k => <Item key={k.id} info={k} />)
|
||||||
}}
|
}
|
||||||
position="top-center"
|
</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 { useEffect } from "react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useRegisterSW } from "virtual:pwa-register/react"
|
import { useRegisterSW } from "virtual:pwa-register/react"
|
||||||
|
import { useToast } from "./useToast"
|
||||||
|
|
||||||
export function usePWA() {
|
export function usePWA() {
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
updateServiceWorker,
|
updateServiceWorker,
|
||||||
} = useRegisterSW()
|
} = useRegisterSW()
|
||||||
|
const toaster = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (needRefresh) {
|
if (needRefresh) {
|
||||||
toast("网站有更新,点击更新", {
|
toaster("网站有更新,点击更新", {
|
||||||
action: {
|
action: {
|
||||||
label: "更新",
|
label: "更新",
|
||||||
onClick: () => updateServiceWorker(true),
|
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 { useAtom } from "jotai"
|
||||||
import { ofetch } from "ofetch"
|
import { ofetch } from "ofetch"
|
||||||
import { useDebounce, useMount } from "react-use"
|
import { useDebounce, useMount } from "react-use"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useLogin } from "./useLogin"
|
import { useLogin } from "./useLogin"
|
||||||
|
import { useToast } from "./useToast"
|
||||||
import { preprocessMetadata, primitiveMetadataAtom } from "~/atoms"
|
import { preprocessMetadata, primitiveMetadataAtom } from "~/atoms"
|
||||||
import { safeParseString } from "~/utils"
|
import { safeParseString } from "~/utils"
|
||||||
|
|
||||||
@ -49,6 +49,7 @@ export async function downloadMetadata(): Promise<PrimitiveMetadata | undefined>
|
|||||||
export function useSync() {
|
export function useSync() {
|
||||||
const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom)
|
const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom)
|
||||||
const { logout, login } = useLogin()
|
const { logout, login } = useLogin()
|
||||||
|
const toaster = useToast()
|
||||||
|
|
||||||
useDebounce(async () => {
|
useDebounce(async () => {
|
||||||
if (primitiveMetadata.action === "manual") {
|
if (primitiveMetadata.action === "manual") {
|
||||||
@ -64,7 +65,8 @@ export function useSync() {
|
|||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.statusCode === 401) {
|
if (e.statusCode === 401) {
|
||||||
toast.error("身份校验失败,请重新登录", {
|
toaster("身份校验失败,请重新登录", {
|
||||||
|
type: "error",
|
||||||
action: {
|
action: {
|
||||||
label: "登录",
|
label: "登录",
|
||||||
onClick: login,
|
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) {
|
export function safeParseString(str: any) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(str)
|
return JSON.parse(str)
|
||||||
@ -5,3 +7,31 @@ export function safeParseString(str: any) {
|
|||||||
return ""
|
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",
|
"btn": "op50 hover:op85",
|
||||||
},
|
},
|
||||||
safelist: [
|
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} color-${k} border-${k} sprinkle-${k} shadow-${k}
|
||||||
bg-${k}-500 color-${k}-500
|
bg-${k}-500 color-${k}-500
|
||||||
dark:bg-${k} dark:color-${k}`.trim().split(/\s+/)).flat(),
|
dark:bg-${k} dark:color-${k}`.trim().split(/\s+/)).flat(),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user