From 346f2ee516b71de4264fbac7e99c1b8b007c2ffd Mon Sep 17 00:00:00 2001 From: Ou Date: Mon, 30 Sep 2024 18:53:57 +0800 Subject: [PATCH] feat: support dnd card in /focus --- server/routes/[id].ts | 6 +- shared/types.ts | 2 +- shared/utils.ts | 12 +- src/components/Card.tsx | 207 +++++++++++++++++++++++++++ src/components/{Main.tsx => Dnd.tsx} | 43 +++--- src/components/NewsCard.tsx | 27 ++-- src/components/Pure.tsx | 118 ++------------- src/routes/index.tsx | 8 +- 8 files changed, 267 insertions(+), 156 deletions(-) create mode 100644 src/components/Card.tsx rename src/components/{Main.tsx => Dnd.tsx} (52%) diff --git a/server/routes/[id].ts b/server/routes/[id].ts index d811990..306e555 100644 --- a/server/routes/[id].ts +++ b/server/routes/[id].ts @@ -3,6 +3,8 @@ import { defineEventHandler, getQuery, getRouterParam, sendProxy } from "h3" export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id") const { latest } = getQuery(event) - if (latest !== undefined) return await sendProxy(event, `https://smzdk.top/api/${id}/new`) - return await sendProxy(event, `https://smzdk.top/api/${id}`) + // https://api-hot.efefee.cn/weibo?cache=false + // https://smzdk.top/api/${id}/new + if (latest !== undefined) return await sendProxy(event, `https://api-hot.efefee.cn/${id}?cache=false`) + return await sendProxy(event, `https://api-hot.efefee.cn/${id}?cache=true`) }) diff --git a/shared/types.ts b/shared/types.ts index 8d68e1b..d73d521 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -15,6 +15,7 @@ export interface NewsItem { author?: string desc?: string url: string + timestamp?: number mobileUrl?: string } @@ -22,7 +23,6 @@ export interface NewsItem { export interface SourceInfo { name: string title: string - subtitle?: string type: string description?: string params?: Record diff --git a/shared/utils.ts b/shared/utils.ts index ebc4a69..d4195c4 100644 --- a/shared/utils.ts +++ b/shared/utils.ts @@ -1,18 +1,20 @@ -export function formatTime(timestamp: string) { +export function relativeTime(timestamp: string | number) { const date = new Date(timestamp) const now = new Date() const diffInSeconds = (now.getTime() - date.getTime()) / 1000 const diffInMinutes = diffInSeconds / 60 const diffInHours = diffInMinutes / 60 - if (diffInSeconds < 60) { - return "刚刚更新" + if (Number.isNaN(date.getDay())) { + return undefined + } else if (diffInSeconds < 60) { + return "刚刚" } else if (diffInMinutes < 60) { const minutes = Math.floor(diffInMinutes) - return `${minutes}分钟前更新` + return `${minutes}分钟前` } else if (diffInHours < 24) { const hours = Math.floor(diffInHours) - return `${hours}小时前更新` + return `${hours}小时前` } else { const month = date.getMonth() + 1 const day = date.getDate() diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000..38d164e --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,207 @@ +import type { SourceID, SourceInfo } from "@shared/types" +import type { UseQueryResult } from "@tanstack/react-query" +import { useQuery } from "@tanstack/react-query" +import { relativeTime } from "@shared/utils" +import clsx from "clsx" +import { CSS } from "@dnd-kit/utilities" +import { useInView } from "react-intersection-observer" +import { useAtom } from "jotai" +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react" +import { sourceList } from "@shared/data" +import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities" +import { useSortable } from "@dnd-kit/sortable" +import { focusSourcesAtom, refetchSourceAtom } from "~/atoms" + +interface ItemsProps extends React.HTMLAttributes { + id: SourceID + withOpacity?: boolean + isDragging?: boolean + listeners?: SyntheticListenerMap +} + +export const CardWrapper = forwardRef(({ id, withOpacity, isDragging, listeners, style, ...props }, dndRef) => { + const ref = useRef(null) + const { ref: inViewRef, inView } = useInView({ + threshold: 0, + }) + + // const bindRef = useCallback((node: HTMLDivElement) => { + // inViewRef(node) + // if (typeof dndRef === "function") { + // dndRef(node) + // } else if (dndRef) { + // dndRef.current = node + // } + // }, [inViewRef, dndRef]) + + useImperativeHandle(dndRef, () => ref.current as HTMLDivElement) + useImperativeHandle(inViewRef, () => ref.current as HTMLDivElement) + + return ( +
+ +
+ ) +}) + +export function SortableCardWrapper(props: ItemsProps) { + const { id } = props + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition: transition || undefined, + } + + return ( + + ) +} + +interface NewsCardProps { + id: SourceID + inView: boolean + isDragging?: boolean + listeners?: SyntheticListenerMap +} + +interface Query { + query: UseQueryResult +} + +function SubTitle({ query }: Query) { + const subTitle = query.data?.type + if (subTitle) return {subTitle} +} + +function UpdateTime({ query }: Query) { + const updateTime = query.data?.updateTime + if (updateTime) return {`${relativeTime(updateTime)}更新`} + if (query.isError) return 获取失败 + return +} + +function Num({ num }: { num: number }) { + const color = ["bg-red-900", "bg-red-500", "bg-red-400"] + return ( + + {num} + + ) +} + +function NewsList({ query }: Query) { + const items = query.data?.data + if (items?.length) { + return ( + <> + {items.slice(0, 20).map((item, i) => ( + + ))} + + ) + } + return ( + <> + {Array.from({ length: 20 }).map((_, i) => i).map(i => ( +
+ + +
+ ))} + + ) +} + +export function NewsCard({ id, inView, isDragging, listeners }: NewsCardProps) { + const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) + const [refetchSource, setRefetchSource] = useAtom(refetchSourceAtom) + const query = useQuery({ + queryKey: [id, refetchSource[id]], + queryFn: async ({ queryKey }) => { + const [_id, _refetchTime] = queryKey as [SourceID, number] + let url = `/api/${_id}` + if (Date.now() - _refetchTime < 1000) { + url = `/api/${_id}?latest` + } + const response = await fetch(url) + return await response.json() as SourceInfo + }, + // refetch 时显示原有的数据 + placeholderData: prev => prev, + staleTime: 1000 * 60 * 5, + enabled: inView, + refetchOnWindowFocus: true, + }) + + const addFocusList = useCallback(() => { + setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) + }, [setFocusSources, focusSources, id]) + const manualRefetch = useCallback(() => { + setRefetchSource(prev => ({ + ...prev, + [id]: Date.now(), + })) + }, [setRefetchSource, id]) + + return ( + <> +
+ + {sourceList[id]} + + +
+
+ +
+
+ +
+
+
+ + ) +} diff --git a/src/components/Main.tsx b/src/components/Dnd.tsx similarity index 52% rename from src/components/Main.tsx rename to src/components/Dnd.tsx index 6dfe1a5..e69a4d2 100644 --- a/src/components/Main.tsx +++ b/src/components/Dnd.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react" +import { useCallback, useState } from "react" import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core" import { DndContext, @@ -9,14 +9,15 @@ import { useSensor, useSensors, } from "@dnd-kit/core" -import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable" -import type { SectionID } from "@shared/types" -import { metadata } from "@shared/data" -import { Item, SortableItem } from "./NewsCard" +import { SortableContext, arrayMove, rectSortingStrategy } from "@dnd-kit/sortable" +import { useAtom } from "jotai" +import type { SourceID } from "@shared/types" +import { GridContainer } from "./Pure" +import { CardWrapper, SortableCardWrapper } from "./Card" +import { focusSourcesAtom } from "~/atoms" -export function Main({ sectionId }: { sectionId: SectionID }) { - // const [items, setItems] = useState(metadata?.[sectionId]?.sourceList ?? []) - const items = useMemo(() => metadata?.[sectionId]?.sourceList ?? [], [sectionId]) +export function Main() { + const [items, setItems] = useAtom(focusSourcesAtom) const [activeId, setActiveId] = useState(null) const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor)) @@ -27,16 +28,16 @@ export function Main({ sectionId }: { sectionId: SectionID }) { const { active, over } = event if (active.id !== over?.id) { - // setItems((items) => { - // const oldIndex = items.indexOf(active.id as any) - // const newIndex = items.indexOf(over!.id as any) + setItems((items) => { + const oldIndex = items.indexOf(active.id as any) + const newIndex = items.indexOf(over!.id as any) - // return arrayMove(items, oldIndex, newIndex) - // }) + return arrayMove(items, oldIndex, newIndex) + }) } setActiveId(null) - }, []) + }, [setItems]) const handleDragCancel = useCallback(() => { setActiveId(null) }, []) @@ -50,20 +51,14 @@ export function Main({ sectionId }: { sectionId: SectionID }) { onDragCancel={handleDragCancel} > -
+ {items.map(id => ( - + ))} -
+
- {!!activeId && } + {!!activeId && } ) diff --git a/src/components/NewsCard.tsx b/src/components/NewsCard.tsx index 9b9b415..1dabcd1 100644 --- a/src/components/NewsCard.tsx +++ b/src/components/NewsCard.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, HTMLAttributes, PropsWithChildren } from "react" +import type { CSSProperties, HTMLAttributes } from "react" import { useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { forwardRef } from "react" @@ -12,14 +12,6 @@ type ItemProps = HTMLAttributes & { listeners?: SyntheticListenerMap } -export function GridContainer({ children }: PropsWithChildren) { - return ( -
- {children} -
- ) -} - export const Item = forwardRef(({ id, withOpacity, isDragging, listeners, style, ...props }, ref) => { const inlineStyles: CSSProperties = { transformOrigin: "50% 50%", @@ -40,16 +32,14 @@ export const Item = forwardRef(({ id, withOpacity, is style={inlineStyles} {...props} > +
+ 你好 +
-
- 你好 -
-
- {id} -
+ {id}
) @@ -76,6 +66,7 @@ export function SortableItem(props: ItemProps) { ref={setNodeRef} style={style} withOpacity={isDragging} + isDragging={isDragging} listeners={listeners} {...attributes} {...props} diff --git a/src/components/Pure.tsx b/src/components/Pure.tsx index f703d9e..d26e676 100644 --- a/src/components/Pure.tsx +++ b/src/components/Pure.tsx @@ -1,116 +1,28 @@ -import type { SourceID, SourceInfo } from "@shared/types" -import { useQuery } from "@tanstack/react-query" -import { formatTime } from "@shared/utils" -import clsx from "clsx" -import { useInView } from "react-intersection-observer" -import { useAtom, useAtomValue } from "jotai" -import { useCallback } from "react" -import { currentSectionAtom, focusSourcesAtom, refetchSourceAtom } from "~/atoms" +import { useAtomValue } from "jotai" +import type { PropsWithChildren } from "react" +import { CardWrapper } from "./Card" +import { currentSectionAtom } from "~/atoms" -export function Main() { - const currentSection = useAtomValue(currentSectionAtom) +export function GridContainer({ children }: PropsWithChildren) { return (
+ {children} +
+ ) +} + +export function Main() { + const currentSection = useAtomValue(currentSectionAtom) + return ( + {currentSection.sourceList.map(id => ( ))} - + ) } - -function CardWrapper({ id }: { id: SourceID }) { - const { ref, inView } = useInView({ - threshold: 0, - }) - return ( -
- -
- ) -} - -function NewsCard({ id, inView }: { id: SourceID, inView: boolean }) { - const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) - const addFocusList = useCallback(() => { - setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) - }, [setFocusSources, focusSources, id]) - const [refetchSource, setRefetchSource] = useAtom(refetchSourceAtom) - const { isPending, error, isFetching, data } = useQuery({ - queryKey: [id, refetchSource[id]], - queryFn: async ({ queryKey }) => { - const [_id, _refetchTime] = queryKey as [SourceID, number] - let url = `/api/${_id}` - if (Date.now() - _refetchTime < 1000) { - url = `/api/${_id}?latest` - } - const response = await fetch(url) - return await response.json() as SourceInfo - }, - // refetch 时显示原有的数据 - placeholderData: prev => prev, - staleTime: 1000 * 60 * 5, - enabled: inView, - refetchOnWindowFocus: true, - }) - - const manualRefetch = useCallback(() => { - setRefetchSource(prev => ({ - ...prev, - [id]: Date.now(), - })) - }, [setRefetchSource, id]) - - if (isPending || !data) { - return ( - <> - {Array.from({ length: 18 }).map((_, i) => i).map(i =>
)} - - ) - } else if (error) { - return
Error:
- } else if (data) { - return ( - <> -
- - { data?.title } - - - { data?.subtitle} - -
-
- {data?.data.slice(0, 20).map((item, index) => ( -
- - { index + 1} - - - {item.title} - -
- ))} -
-
- - {formatTime(data!.updateTime)} - -
-
-
- - ) - } -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 13807b3..997b89d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,8 +5,8 @@ import clsx from "clsx" import { useSetAtom } from "jotai" import { useEffect } from "react" import { currentSectionAtom } from "~/atoms" -// import { Main } from "~/components/Main" -import { Main } from "~/components/Pure" +import { Main as DndMain } from "~/components/Dnd" +import { Main as PureMain } from "~/components/Pure" export const Route = createFileRoute("/")({ validateSearch: (search: any) => ({ @@ -36,7 +36,9 @@ function IndexComponent() { ))} -
+ { + id === "focus" ? : + } ) }