From e6ba8b23a7b5122ade6b0b6e344de3790ff7a150 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 1 Jul 2023 15:38:49 +0800 Subject: [PATCH] feat: say view Signed-off-by: Innei --- README.md | 4 +- .../(topic-detail)/topics/[slug]/page.tsx | 2 +- src/app/posts/page.tsx | 10 +- src/app/says/layout.tsx | 11 ++ src/app/says/page.tsx | 148 ++++++++++++++++++ src/app/says/query.ts | 1 + src/components/layout/container/Wider.tsx | 16 ++ .../ui/code-highlighter/CodeHighlighter.tsx | 8 +- src/components/ui/masonry/Masonry.tsx | 60 +++++++ src/components/ui/masonry/index.ts | 1 + src/components/widgets/comment/Comments.tsx | 2 +- .../widgets/shared/LoadMoreIndicator.tsx | 12 +- .../widgets/shared/NothingFound.tsx | 12 ++ src/hooks/common/use-is-dark.ts | 6 + src/lib/color.ts | 85 ++++++++++ 15 files changed, 359 insertions(+), 19 deletions(-) create mode 100644 src/app/says/layout.tsx create mode 100644 src/app/says/page.tsx create mode 100644 src/app/says/query.ts create mode 100644 src/components/layout/container/Wider.tsx create mode 100644 src/components/ui/masonry/Masonry.tsx create mode 100644 src/components/ui/masonry/index.ts create mode 100644 src/components/widgets/shared/NothingFound.tsx create mode 100644 src/hooks/common/use-is-dark.ts create mode 100644 src/lib/color.ts diff --git a/README.md b/README.md index 83c8747030..ecf4f04efc 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,6 @@ A theme for [Mix Space](https://github.com/mx-space) ## License -2023 Innei, GPLv3. \ No newline at end of file +2022 © Innei, Released under the MIT License. + +> [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/) \ No newline at end of file diff --git a/src/app/(note-topic)/notes/(topic-detail)/topics/[slug]/page.tsx b/src/app/(note-topic)/notes/(topic-detail)/topics/[slug]/page.tsx index 2bd46bb483..e4fd6e3cff 100644 --- a/src/app/(note-topic)/notes/(topic-detail)/topics/[slug]/page.tsx +++ b/src/app/(note-topic)/notes/(topic-detail)/topics/[slug]/page.tsx @@ -83,7 +83,7 @@ export default function Page() { }), )} - {hasNextPage && } + {hasNextPage && } diff --git a/src/app/posts/page.tsx b/src/app/posts/page.tsx index d788f0236e..73d8ae897d 100644 --- a/src/app/posts/page.tsx +++ b/src/app/posts/page.tsx @@ -1,8 +1,8 @@ -import { EmptyIcon } from '~/components/icons/empty' import { NormalContainer } from '~/components/layout/container/Normal' import { BottomToUpTransitionView } from '~/components/ui/transition/BottomToUpTransitionView' import { PostItem } from '~/components/widgets/post/PostItem' import { PostPagination } from '~/components/widgets/post/PostPagination' +import { NothingFound } from '~/components/widgets/shared/NothingFound' import { apiClient } from '~/utils/request' interface Props { @@ -25,13 +25,7 @@ export default async (props: Props) => { const { data, pagination } = $serialized if (!data?.length) { - return ( - - -

这里空空如也

-

稍后再来看看吧!

-
- ) + return } return ( diff --git a/src/app/says/layout.tsx b/src/app/says/layout.tsx new file mode 100644 index 0000000000..1cd8a51775 --- /dev/null +++ b/src/app/says/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next' +import type { PropsWithChildren } from 'react' + +import { WiderContainer } from '~/components/layout/container/Wider' + +export const metadata: Metadata = { + title: '一言', +} +export default async function Layout(props: PropsWithChildren) { + return {props.children} +} diff --git a/src/app/says/page.tsx b/src/app/says/page.tsx new file mode 100644 index 0000000000..56799fe8b7 --- /dev/null +++ b/src/app/says/page.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useInfiniteQuery } from '@tanstack/react-query' +import { memo, useRef } from 'react' +import { m } from 'framer-motion' +import Markdown from 'markdown-to-jsx' +import type { SayModel } from '@mx-space/api-client' +import type { MarkdownToJSX } from 'markdown-to-jsx' + +import { useIsMobile } from '~/atoms' +import { Loading } from '~/components/ui/loading' +import { Masonry } from '~/components/ui/masonry' +import { RelativeTime } from '~/components/ui/relative-time' +import { BottomToUpTransitionView } from '~/components/ui/transition/BottomToUpTransitionView' +import { LoadMoreIndicator } from '~/components/widgets/shared/LoadMoreIndicator' +import { NothingFound } from '~/components/widgets/shared/NothingFound' +import { useIsDark } from '~/hooks/common/use-is-dark' +import { addAlphaToHSL, getColorScheme, stringToHue } from '~/lib/color' +import { apiClient } from '~/utils/request' + +import { sayQueryKey } from './query' + +export default function Page() { + const { fetchNextPage, hasNextPage, data, isLoading } = useInfiniteQuery({ + queryKey: sayQueryKey, + queryFn: async ({ pageParam }) => { + const data = await apiClient.say.getAllPaginated(pageParam) + return data + }, + getNextPageParam: (lastPage) => + lastPage.pagination.hasNextPage + ? lastPage.pagination.currentPage + 1 + : undefined, + }) + + const isMobile = useIsMobile() + + if (isLoading) { + return + } + + if (!data || data.pages.length === 0) return + + const list = data.pages + .map((page) => page.data) + .flat() + .map((say) => { + return { + text: say.text, + item: say, + id: say.id, + } + }) + + return ( +
+
+

一言

+
+ +
+ + + {hasNextPage && ( + + + + )} +
+
+ ) +} +const placeholderData = Array.from({ length: 10 }).map((_, index) => ({ + index, + text: '', + id: index.toFixed(), + item: {} as SayModel, +})) +const SaySkeleton = memo(() => { + return ( +
+
+
+
+
+
+
+
+
+ ) +}) +SaySkeleton.displayName = 'SaySkeleton' + +const options = { + disableParsingRawHTML: true, + forceBlock: true, +} satisfies MarkdownToJSX.Options + +const Item = memo<{ + item: SayModel + index: number +}>(({ item: say, index: i }) => { + const hasSource = !!say.source + const hasAuthor = !!say.author + // const color = colorsMap.get(say.id) + const { dark: darkColors, light: lightColors } = useRef( + getColorScheme(stringToHue(say.id)), + ).current + const isDark = useIsDark() + + return ( + + + {`${say.text}`} +
+
+ 发布于 + +
+
+
+ {hasSource && `出自“${say.source}”`} + {hasSource && hasAuthor && ', '} + {hasAuthor && `作者:${say.author}`} + {!hasAuthor && !hasSource && '站长说'} +
+
+
+
+
+ ) +}) +Item.displayName = 'Item' diff --git a/src/app/says/query.ts b/src/app/says/query.ts new file mode 100644 index 0000000000..f346e62b6c --- /dev/null +++ b/src/app/says/query.ts @@ -0,0 +1 @@ +export const sayQueryKey = ['says'] diff --git a/src/components/layout/container/Wider.tsx b/src/components/layout/container/Wider.tsx new file mode 100644 index 0000000000..cde393fb3b --- /dev/null +++ b/src/components/layout/container/Wider.tsx @@ -0,0 +1,16 @@ +import { clsxm } from '~/utils/helper' + +export const WiderContainer: Component = (props) => { + const { children, className } = props + + return ( +
+ {children} +
+ ) +} diff --git a/src/components/ui/code-highlighter/CodeHighlighter.tsx b/src/components/ui/code-highlighter/CodeHighlighter.tsx index 79aab6e24f..eda6d2d2a4 100644 --- a/src/components/ui/code-highlighter/CodeHighlighter.tsx +++ b/src/components/ui/code-highlighter/CodeHighlighter.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useInsertionEffect, useRef } from 'react' -import { useTheme } from 'next-themes' import type { FC } from 'react' import { useIsPrintMode } from '~/atoms/css-media' +import { useIsDark } from '~/hooks/common/use-is-dark' import { loadScript, loadStyleSheet } from '~/lib/load-script' import { toast } from '~/lib/toast' @@ -28,13 +28,13 @@ export const HighLighter: FC = (props) => { }, [value]) const prevThemeCSS = useRef>() - const { theme, systemTheme } = useTheme() const isPrintMode = useIsPrintMode() + const isDark = useIsDark() useInsertionEffect(() => { const css = loadStyleSheet( `https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism-themes/1.9.0/prism-one-${ - isPrintMode ? 'light' : theme === 'system' ? systemTheme : theme + isPrintMode ? 'light' : isDark ? 'dark' : 'light' }.css`, ) @@ -46,7 +46,7 @@ export const HighLighter: FC = (props) => { } prevThemeCSS.current = css - }, [theme, isPrintMode, systemTheme]) + }, [isDark, isPrintMode]) useInsertionEffect(() => { loadStyleSheet( 'https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism/1.23.0/plugins/line-numbers/prism-line-numbers.min.css', diff --git a/src/components/ui/masonry/Masonry.tsx b/src/components/ui/masonry/Masonry.tsx new file mode 100644 index 0000000000..314a14e417 --- /dev/null +++ b/src/components/ui/masonry/Masonry.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react' + +interface MasonryProps { + list: Array<{ id: string; text: string; item: T }> + columns: number + Component: React.NamedExoticComponent<{ + text: string + item: T + index: number + }> +} + +export function Masonry({ list, columns, Component }: MasonryProps) { + const [columnWrapperStyle, setColumnWrapperStyle] = useState({}) + const [elements, setElements] = useState>([]) + + useEffect(() => { + setColumnWrapperStyle({ + columnCount: columns, + columnGap: '1em', + }) + + const elements = list.reduce( + (accumulator: Array, item, i) => { + const element = ( +
+ +
+ ) + + const columnIndex = i % columns + accumulator[columnIndex] = [ + ...(accumulator[columnIndex] || []), + element, + ] + + return accumulator + }, + [], + ) + + setElements(elements) + }, [list, columns]) + + return ( +
+ {elements.map((colElements, i) => ( +
+ {colElements} +
+ ))} +
+ ) +} diff --git a/src/components/ui/masonry/index.ts b/src/components/ui/masonry/index.ts new file mode 100644 index 0000000000..05bac51cf3 --- /dev/null +++ b/src/components/ui/masonry/index.ts @@ -0,0 +1 @@ +export * from './Masonry' diff --git a/src/components/widgets/comment/Comments.tsx b/src/components/widgets/comment/Comments.tsx index 593fc0c4b8..0add11f96f 100644 --- a/src/components/widgets/comment/Comments.tsx +++ b/src/components/widgets/comment/Comments.tsx @@ -61,7 +61,7 @@ export const Comments: FC = ({ refId }) => { )} {hasNextPage && ( - + )} diff --git a/src/components/widgets/shared/LoadMoreIndicator.tsx b/src/components/widgets/shared/LoadMoreIndicator.tsx index 5b756761c6..13afd430cc 100644 --- a/src/components/widgets/shared/LoadMoreIndicator.tsx +++ b/src/components/widgets/shared/LoadMoreIndicator.tsx @@ -5,13 +5,17 @@ import { useInView } from 'react-intersection-observer' import { Loading } from '~/components/ui/loading' export const LoadMoreIndicator: Component<{ - onClick: () => void -}> = ({ onClick, children }) => { + onLoading: () => void +}> = ({ onLoading, children, className }) => { const { ref } = useInView({ rootMargin: '1px', onChange(inView) { - if (inView) onClick() + if (inView) onLoading() }, }) - return
{children ?? }
+ return ( +
+ {children ?? } +
+ ) } diff --git a/src/components/widgets/shared/NothingFound.tsx b/src/components/widgets/shared/NothingFound.tsx new file mode 100644 index 0000000000..e0148ba641 --- /dev/null +++ b/src/components/widgets/shared/NothingFound.tsx @@ -0,0 +1,12 @@ +import { EmptyIcon } from '~/components/icons/empty' +import { NormalContainer } from '~/components/layout/container/Normal' + +export const NothingFound: Component = () => { + return ( + + +

这里空空如也

+

稍后再来看看吧!

+
+ ) +} diff --git a/src/hooks/common/use-is-dark.ts b/src/hooks/common/use-is-dark.ts new file mode 100644 index 0000000000..3210058cbd --- /dev/null +++ b/src/hooks/common/use-is-dark.ts @@ -0,0 +1,6 @@ +import { useTheme } from 'next-themes' + +export const useIsDark = () => { + const { theme, systemTheme } = useTheme() + return theme === 'dark' || (theme === 'system' && systemTheme === 'dark') +} diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000000..7bb3e17843 --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,85 @@ +const getRandomColor = ( + lightness: [number, number], + saturation: [number, number], + hue: number, +) => { + const satAccent = Math.floor( + Math.random() * (saturation[1] - saturation[0] + 1) + saturation[0], + ) + const lightAccent = Math.floor( + Math.random() * (lightness[1] - lightness[0] + 1) + lightness[0], + ) + + // Generate the background color by increasing the lightness and decreasing the saturation + const satBackground = satAccent > 30 ? satAccent - 30 : 0 + const lightBackground = lightAccent < 80 ? lightAccent + 20 : 100 + + return { + accent: `hsl(${hue}, ${satAccent}%, ${lightAccent}%)`, + background: `hsl(${hue}, ${satBackground}%, ${lightBackground}%)`, + } +} + +export function stringToHue(str: string) { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + const hue = hash % 360 + return hue < 0 ? hue + 360 : hue +} + +export const getColorScheme = (hue?: number) => { + const baseHue = hue ?? Math.floor(Math.random() * 361) + const complementaryHue = (baseHue + 180) % 360 + + // For light theme, we limit the lightness between 40 and 70 to avoid too bright colors for accent + const lightColors = getRandomColor([40, 70], [70, 90], baseHue) + + // For dark theme, we limit the lightness between 20 and 50 to avoid too dark colors for accent + const darkColors = getRandomColor([20, 50], [70, 90], complementaryHue) + + return { + light: { + accent: lightColors.accent, + background: lightColors.background, + }, + dark: { + accent: darkColors.accent, + background: darkColors.background, + }, + } +} +export function addAlphaToHex(hex: string, alpha: number): string { + if (!/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { + throw new Error('Invalid hex color value') + } + + let color = '' + if (hex.length === 4) { + color = `#${[1, 2, 3] + .map( + (index) => + parseInt(hex.charAt(index), 16).toString(16) + + parseInt(hex.charAt(index), 16).toString(16), + ) + .join('')}` + } else { + color = hex + } + + const r = parseInt(color.substr(1, 2), 16) + const g = parseInt(color.substr(3, 2), 16) + const b = parseInt(color.substr(5, 2), 16) + + return `rgba(${r},${g},${b},${alpha})` +} + +export function addAlphaToHSL(hsl: string, alpha: number): string { + if (!/^hsl\((\d{1,3}),\s*([\d.]+)%,\s*([\d.]+)%\)$/.test(hsl)) { + throw new Error('Invalid HSL color value') + } + + const hsla = `${hsl.slice(0, -1)}, ${alpha})` + return hsla.replace('hsl', 'hsla') +}