(fn)
7 | useIsomorphicLayoutEffect(() => {
8 | ref.current = fn
9 | }, [fn])
10 |
11 | return useCallback((...args: any[]) => ref.current(...args), []) as T
12 | }
13 |
--------------------------------------------------------------------------------
/src/hooks/biz/use-ack-read-count.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | import { apiClient } from '~/lib/request'
4 | import { queryClient } from '~/providers/root/react-query-provider'
5 |
6 | export const useAckReadCount = (type: 'post' | 'note', id: string) => {
7 | useEffect(() => {
8 | queryClient.fetchQuery({
9 | queryKey: ['ack-read-count', type, id],
10 | queryFn: async () => apiClient.ack.read(type, id),
11 | })
12 | }, [])
13 | }
14 |
--------------------------------------------------------------------------------
/src/queries/definition/page.ts:
--------------------------------------------------------------------------------
1 | import { apiClient } from '~/lib/request'
2 |
3 | import { defineQuery } from '../helper'
4 |
5 | export const page = {
6 | bySlug: (slug: string) =>
7 | defineQuery({
8 | queryKey: ['page', slug],
9 |
10 | queryFn: async ({ queryKey }) => {
11 | const [, slug] = queryKey
12 |
13 | const data = await apiClient.page.getBySlug(slug)
14 |
15 | return data.$serialized
16 | },
17 | }),
18 | }
19 |
--------------------------------------------------------------------------------
/SAY.md:
--------------------------------------------------------------------------------
1 |
2 | ## 动机
3 |
4 | Kami 的代码已经超过 3 年,实在变得难以维护,一堆屎一样的代码。自己看了都感到恶心。其次是太多的 Hydration Error 看着心烦,换个风格也挺好。
5 |
6 | 本次重写不仅在风格上的不同,在代码层面上也更加符合 React 哲学,使用了非常牛逼的 Jotai 作为状态管理,🍞老师果然说的没错,Jotai yyds。
7 |
8 | 几乎重写了 80% 的原代码,覆盖了几个主要页面。
9 |
10 | ## Status
11 |
12 | **WIP**
13 |
14 | ## 设计细节
15 |
16 | ### 本站依旧接入了 WebSocket 连接,通知和更新当前的活动。
17 |
18 | - 发布文章站内通知
19 | - 文章的实时修改,实时反映
20 | - 实时评论 WIP
21 |
22 | ### 头部
23 |
24 | - 4 种不同的状态
25 |
26 | ### FAB
27 |
28 | 对移动端进行了优化,调整了尺寸为了更好的点击。
--------------------------------------------------------------------------------
/src/app/(app)/(note-topic)/notes/topics/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import type { PropsWithChildren } from 'react'
3 |
4 | import { NormalContainer } from '~/components/layout/container/Normal'
5 |
6 | export const dynamic = 'force-dynamic'
7 | export const metadata: Metadata = {
8 | title: '专栏',
9 | }
10 |
11 | export default async function Layout(props: PropsWithChildren) {
12 | return {props.children}
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/hoc/with-no-ssr.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, PropsWithChildren } from 'react'
2 |
3 | import { useIsClientTransition } from '~/hooks/common/use-is-client'
4 |
5 | export const withNoSSR = (
6 | Component: FC>,
7 | ): FC> => {
8 | return function NoSSRWrapper(props: PropsWithChildren) {
9 | const isClient = useIsClientTransition()
10 | if (!isClient) return null
11 | return
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/icons/calendar.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 | import * as React from 'react'
3 |
4 | export function MdiCalendar(props: SVGProps) {
5 | return (
6 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/(app)/categories/[slug]/query.ts:
--------------------------------------------------------------------------------
1 | import { apiClient } from '~/lib/request'
2 | import { defineQuery } from '~/queries/helper'
3 |
4 | export const getPageBySlugQuery = (slug: string) =>
5 | defineQuery({
6 | queryKey: ['category', slug],
7 | queryFn: async ({ queryKey }) => {
8 | const [, slug] = queryKey
9 |
10 | const data = await apiClient.category.getCategoryByIdOrSlug(slug)
11 |
12 | return {
13 | ...data,
14 | }
15 | },
16 | })
17 |
--------------------------------------------------------------------------------
/src/components/modules/shared/BlockLoading.tsx:
--------------------------------------------------------------------------------
1 | import { clsxm } from '~/lib/helper'
2 |
3 | export const BlockLoading: Component<{
4 | style?: React.CSSProperties
5 | }> = (props) => {
6 | return (
7 |
14 | {props.children}
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/hooks/common/use-debounce-value.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export default function useDebounceValue(value: T, delay: number): T {
4 | const [debouncedValue, setDebouncedValue] = useState(value)
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value)
9 | }, delay)
10 |
11 | return () => {
12 | clearTimeout(handler)
13 | }
14 | }, [value, delay])
15 |
16 | return debouncedValue
17 | }
18 |
--------------------------------------------------------------------------------
/src/hooks/common/use-sync-effect.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | type CleanupFn = () => void | undefined
4 | const noop = () => {}
5 | export const useSyncEffectOnce = (effect: (() => CleanupFn) | (() => void)) => {
6 | const ref = useRef(false)
7 | const cleanupRef = useRef<(() => void) | null>(null)
8 | useEffect(() => cleanupRef.current || noop, [])
9 |
10 | if (ref.current) return
11 | cleanupRef.current = effect() || null
12 | ref.current = true
13 | }
14 |
--------------------------------------------------------------------------------
/src/constants/env.ts:
--------------------------------------------------------------------------------
1 | import { env } from 'next-runtime-env'
2 |
3 | import { isClientSide, isDev } from '~/lib/env'
4 |
5 | export const API_URL: string = (() => {
6 | if (isDev) return env('NEXT_PUBLIC_API_URL') || ''
7 |
8 | if (isClientSide && env('NEXT_PUBLIC_CLIENT_API_URL')) {
9 | return env('NEXT_PUBLIC_CLIENT_API_URL') || ''
10 | }
11 |
12 | return env('NEXT_PUBLIC_API_URL') || '/api/v2'
13 | })() as string
14 | export const GATEWAY_URL = env('NEXT_PUBLIC_GATEWAY_URL') || ''
15 |
--------------------------------------------------------------------------------
/src/styles/image-zoom.css:
--------------------------------------------------------------------------------
1 | /* image-zoom */
2 | .medium-zoom-overlay {
3 | z-index: 99;
4 |
5 | @apply !bg-zinc-50 dark:!bg-neutral-900;
6 | }
7 |
8 | /* .medium-zoom-overlay + .medium-zoom-image {
9 | } */
10 |
11 | .medium-zoom-image {
12 | border-radius: 0.5rem;
13 | transition: border-radius 0.3s ease-in-out;
14 | }
15 | .medium-zoom-image.medium-zoom-image--opened {
16 | border-radius: 0;
17 |
18 | z-index: 100;
19 | opacity: 1;
20 | transition: all 0.5s ease-in-out;
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/common/ScrollTop.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { usePathname } from 'next/navigation'
4 | import { memo, useEffect } from 'react'
5 |
6 | import { isDev } from '~/lib/env'
7 | import { springScrollToTop } from '~/lib/scroller'
8 |
9 | export const ScrollTop = memo(() => {
10 | const pathname = usePathname()
11 | useEffect(() => {
12 | if (isDev) return
13 | springScrollToTop()
14 | }, [pathname])
15 | return null
16 | })
17 |
18 | ScrollTop.displayName = 'ScrollTop'
19 |
--------------------------------------------------------------------------------
/src/hooks/common/use-is-active.ts:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from 'react'
2 |
3 | const subscribe = (cb: () => void) => {
4 | document.addEventListener('visibilitychange', cb)
5 | return () => {
6 | document.removeEventListener('visibilitychange', cb)
7 | }
8 | }
9 |
10 | const getSnapshot = () => document.visibilityState === 'visible'
11 | const getServerSnapshot = () => true
12 | export const usePageIsActive = () =>
13 | useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
14 |
--------------------------------------------------------------------------------
/src/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import { Manrope, Noto_Serif_SC } from 'next/font/google'
2 |
3 | const sansFont = Manrope({
4 | subsets: ['latin'],
5 | weight: ['300', '400', '500'],
6 | variable: '--font-sans',
7 | display: 'swap',
8 | })
9 |
10 | const serifFont = Noto_Serif_SC({
11 | subsets: ['latin'],
12 | weight: ['400'],
13 | variable: '--font-serif',
14 | display: 'swap',
15 | // adjustFontFallback: false,
16 | fallback: ['Noto Serif SC'],
17 | })
18 |
19 | export { sansFont, serifFont }
20 |
--------------------------------------------------------------------------------
/storybook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": [
4 | ".",
5 | "./src"
6 | ],
7 | "exclude": [
8 | "dist",
9 | "build",
10 | "node_modules"
11 | ],
12 | "compilerOptions": {
13 | "baseUrl": ".",
14 | "paths": {
15 | "~": [
16 | "../src"
17 | ],
18 | "~/*": [
19 | "../src/*"
20 | ],
21 | },
22 | "lib": [
23 | "dom",
24 | "dom.iterable",
25 | "esnext"
26 | ]
27 | }
28 | }
--------------------------------------------------------------------------------
/src/queries/helper.ts:
--------------------------------------------------------------------------------
1 | import type { FetchQueryOptions, QueryKey } from '@tanstack/react-query'
2 |
3 | export const defineQuery = <
4 | TQueryFnData = unknown,
5 | TError = unknown,
6 | TData = TQueryFnData,
7 | TQueryKey extends QueryKey = QueryKey,
8 | >(
9 | options: FetchQueryOptions,
10 | ): Omit<
11 | FetchQueryOptions,
12 | 'queryKey'
13 | > & { queryKey: string[] } => {
14 | return options as any
15 | }
16 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true,
4 | "tailwindCSS.experimental.classRegex": [
5 | [
6 | "tv\\(([^)]*)\\)",
7 | "[\"'`]([^\"'`]*).*?[\"'`]",
8 | // match variants className
9 | ]
10 | ],
11 | // https://biomejs.dev/reference/vscode/#fix-on-save
12 | "editor.codeActionsOnSave": {
13 | "quickfix.biome": "explicit",
14 | "source.organizeImports.biome": "explicit"
15 | }
16 | }
--------------------------------------------------------------------------------
/ci-release-build.sh:
--------------------------------------------------------------------------------
1 | #!env bash
2 | set -e
3 | CWD=$(pwd)
4 |
5 | npm run build
6 | cd .next
7 | pwd
8 | rm -rf cache
9 | cp -r ../public ./standalone/public
10 |
11 | cd ./standalone
12 | echo ';process.title = "Shiro (NextJS)"' >>server.js
13 | mv ../static/ ./.next/static
14 |
15 | cp $CWD/ecosystem.standalone.config.cjs ./ecosystem.config.js
16 | cp $CWD/.env.template .env
17 |
18 | cd ..
19 |
20 | mkdir -p $CWD/assets
21 | rm -rf $CWD/assets/release.zip
22 | zip --symlinks -r $CWD/assets/release.zip ./*
23 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/note-editing/sidebar/DateInput.tsx:
--------------------------------------------------------------------------------
1 | import { SidebarDateInputField } from '../../writing/SidebarDateInputField'
2 | import { useNoteModelSingleFieldAtom } from '../data-provider'
3 |
4 | export const CustomCreatedInput = () => (
5 |
6 | )
7 |
8 | export const PublicAtInput = () => (
9 |
13 | )
14 |
--------------------------------------------------------------------------------
/src/app/api/leetcode/route.ts:
--------------------------------------------------------------------------------
1 | import type { NextRequest } from 'next/server'
2 | import { NextResponse } from 'next/server'
3 |
4 | export const POST = async (req: NextRequest) => {
5 | const requestBody = await req.json()
6 |
7 | const response = await fetch('https://leetcode.cn/graphql/', {
8 | method: 'POST',
9 | headers: {
10 | 'Content-Type': 'application/json',
11 | },
12 | body: JSON.stringify(requestBody),
13 | })
14 |
15 | return NextResponse.json(await response.json())
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/modules/comment/CommentBox/constants.ts:
--------------------------------------------------------------------------------
1 | import { sample } from '~/lib/lodash'
2 |
3 | const placeholderCopywrites = [
4 | '在这里说点什么呢。',
5 | '小可爱,你想说点什么呢?',
6 | '或许此地可以留下足迹',
7 | '你的留言是我前进的动力!',
8 | '说点什么吧,我会好好听的。',
9 | '来一发评论,送你一个小星星!',
10 | '你的评论会让我更加努力哦!',
11 | '留下你的足迹,让我知道你来过。',
12 | '我在这里等你的留言呢!',
13 | '你的评论是我最大的动力!',
14 | '来一发评论,让我知道你的想法吧!',
15 | ]
16 | export const getRandomPlaceholder = () => sample(placeholderCopywrites)
17 |
18 | export const MAX_COMMENT_TEXT_LENGTH = 500
19 |
--------------------------------------------------------------------------------
/src/components/ui/image/ZoomedImage.module.css:
--------------------------------------------------------------------------------
1 | .error,
2 | .loading {
3 | @apply opacity-0;
4 | }
5 |
6 | .loaded {
7 | animation: imageLoad 500ms ease-in-out forwards;
8 | }
9 |
10 | @keyframes imageLoad {
11 | 0% {
12 | mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 150% 0 /
13 | 400% no-repeat;
14 | opacity: 0.2;
15 | }
16 | 100% {
17 | mask: linear-gradient(90deg, #000 25%, #000000e6 50%, #00000000) 0 / 400%
18 | no-repeat;
19 | opacity: 1;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/constants/keys.ts:
--------------------------------------------------------------------------------
1 | export const enum EmitKeyMap {
2 | EditDataUpdate = 'editDataUpdate',
3 |
4 | Publish = 'Publish',
5 | Refetch = 'Refetch',
6 |
7 | SocketConnected = 'SocketConnected',
8 | SocketDisconnected = 'SocketDisconnected',
9 | }
10 |
11 | export const CacheKeyMap = {
12 | RootData: 'root-data',
13 | AggregateTop: 'aggregate-top',
14 | PostListWithPage: (current: number) => CacheKeyMap.PostList + current,
15 | PostList: 'post-list:',
16 | Post: (id: string) => `post-${id}`,
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/crossbell/XLogEnabled.tsx:
--------------------------------------------------------------------------------
1 | import { lazy, Suspense, useMemo } from 'react'
2 |
3 | export const XLogEnable = () =>
4 | 'ethereum' in globalThis ? : null
5 |
6 | const XlogSwitchLazy = () => {
7 | const Component = useMemo(
8 | () =>
9 | lazy(() =>
10 | import('./XlogSwitch').then((mo) => ({ default: mo.XlogSwitch })),
11 | ),
12 | [],
13 | )
14 | return (
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/ui/modal/stacked/types.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, PropsWithChildren, ReactNode } from 'react'
2 |
3 | import type { ModalContentPropsInternal } from './context'
4 |
5 | export interface ModalProps {
6 | title: ReactNode
7 | CustomModalComponent?: FC
8 | content: FC
9 | clickOutsideToDismiss?: boolean
10 | modalClassName?: string
11 | modalContainerClassName?: string
12 |
13 | max?: boolean
14 |
15 | wrapper?: FC
16 |
17 | overlay?: boolean
18 | }
19 |
--------------------------------------------------------------------------------
/standalone-bundle.sh:
--------------------------------------------------------------------------------
1 | #!env bash
2 | set -e
3 | CWD=$(pwd)
4 |
5 | cd .next
6 | pwd
7 | rm -rf cache
8 | # cp ../next.config.mjs ./standalone/next.config.mjs
9 | cp -r ../public ./standalone/public
10 |
11 | cd ./standalone
12 | echo ';process.title = "Shiro (NextJS)"' >>server.js
13 | mv ../static/ ./.next/static
14 |
15 | cp $CWD/ecosystem.standalone.config.cjs ./ecosystem.config.js
16 |
17 | cd ..
18 |
19 | mkdir -p $CWD/assets
20 | rm -rf $CWD/assets/release.zip
21 | zip --symlinks -r $CWD/assets/release.zip ./*
22 |
--------------------------------------------------------------------------------
/src/components/modules/note/index.ts:
--------------------------------------------------------------------------------
1 | export * from './NoteActionAside'
2 | export * from './NoteBanner'
3 | export * from './NoteBottomTopic'
4 | export * from './NoteFooterNavigation'
5 | export * from './NoteHideIfSecret'
6 | export * from './NoteLeftSidebar'
7 | export * from './NoteMainContainer'
8 | export * from './NoteMetaBar'
9 | export * from './NotePasswordForm'
10 | export * from './NoteTimeline'
11 | export * from './NoteTopicDetail'
12 | export * from './NoteTopicInfo'
13 | export * from './NoteTopicMarkdownRender'
14 |
--------------------------------------------------------------------------------
/src/components/ui/portal/provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react'
2 |
3 | import { isClientSide } from '~/lib/env'
4 |
5 | export const useRootPortal = () => {
6 | const ctx = useContext(RootPortalContext)
7 | if (!isClientSide) {
8 | return null
9 | }
10 | return ctx.to || document.body
11 | }
12 |
13 | const RootPortalContext = createContext<{
14 | to?: HTMLElement | undefined
15 | }>({
16 | to: undefined,
17 | })
18 |
19 | export const RootPortalProvider = RootPortalContext.Provider
20 |
--------------------------------------------------------------------------------
/src/components/icons/fa-hash.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function FeHash(props: SVGProps) {
4 | return (
5 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/ui/modal/stacked/overlay.tsx:
--------------------------------------------------------------------------------
1 | import { m } from 'motion/react'
2 |
3 | import { RootPortal } from '../../portal'
4 |
5 | export const ModalOverlay = ({ zIndex }: { zIndex?: number }) => (
6 |
7 |
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/src/components/ui/transition/typings.ts:
--------------------------------------------------------------------------------
1 | import type { HTMLMotionProps, m, TargetAndTransition } from 'motion/react'
2 |
3 | export interface BaseTransitionProps
4 | extends Omit, 'ref'> {
5 | duration?: number
6 |
7 | timeout?: {
8 | exit?: number
9 | enter?: number
10 | }
11 |
12 | delay?: number
13 |
14 | animation?: {
15 | enter?: TargetAndTransition['transition']
16 | exit?: TargetAndTransition['transition']
17 | }
18 |
19 | lcpOptimization?: boolean
20 | as?: keyof typeof m
21 | }
22 |
--------------------------------------------------------------------------------
/storybook/mock-packages/next_navigation/index.js:
--------------------------------------------------------------------------------
1 | export const useRouter = () => {
2 | return {
3 | push(path) {
4 | location.pathname = path
5 | },
6 | }
7 | }
8 |
9 | export const usePathname = () => location.pathname
10 |
11 | export const useSearchParams = () => {
12 | const params = new URLSearchParams(location.search)
13 | return {
14 | get(key) {
15 | return params.get(key)
16 | },
17 | }
18 | }
19 | export const notFound = () => {}
20 | export const nav = () => {}
21 | export const redirect = () => {}
22 |
--------------------------------------------------------------------------------
/src/app/(app)/(home)/components/types.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | RecentComment,
3 | RecentLike,
4 | RecentNote,
5 | RecentPost,
6 | RecentRecent,
7 | } from '@mx-space/api-client'
8 |
9 | export type ReactActivityType =
10 | | ({
11 | bizType: 'comment'
12 | } & RecentComment)
13 | | ({
14 | bizType: 'note'
15 | } & RecentNote)
16 | | ({
17 | bizType: 'post'
18 | } & RecentPost)
19 | | ({
20 | bizType: 'recent'
21 | } & RecentRecent)
22 | | ({
23 | bizType: 'like'
24 | } & RecentLike)
25 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/writing/PresentComponentFab.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react'
2 |
3 | import { FABPortable } from '~/components/ui/fab'
4 | import { PresentSheet } from '~/components/ui/sheet'
5 | import { Noop } from '~/lib/noop'
6 |
7 | export const PresentComponentFab: FC<{
8 | Component: FC
9 | }> = ({ Component }) => (
10 |
11 |
12 |
13 |
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/src/components/icons/pen.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function MdiFountainPenTip(props: SVGProps) {
4 | return (
5 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/modules/comment/CommentBox/AuthedInputSkeleton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import clsx from 'clsx'
4 |
5 | export const CommentBoxAuthedInputSkeleton = () => {
6 | const color = 'bg-gray-200/50 dark:bg-zinc-800/50'
7 | return (
8 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/providers/root/debug-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
4 | import type { PropsWithChildren, ReactElement } from 'react'
5 | import { Suspense } from 'react'
6 |
7 | export const DebugProvider = ({
8 | children,
9 | }: PropsWithChildren): ReactElement => (
10 | <>
11 |
12 |
13 |
14 |
15 |
16 | {children}
17 | >
18 | )
19 |
--------------------------------------------------------------------------------
/src/components/layout/container/Normal.tsx:
--------------------------------------------------------------------------------
1 | import { clsxm } from '~/lib/helper'
2 |
3 | import { HeaderHideBg } from '../header/hooks'
4 |
5 | export const NormalContainer: Component = (props) => {
6 | const { children, className } = props
7 |
8 | return (
9 |
16 | {children}
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/layout/root/Root.tsx:
--------------------------------------------------------------------------------
1 | import { ClientOnly } from '~/components/common/ClientOnly'
2 | import { FABContainer } from '~/components/ui/fab'
3 |
4 | import { Content } from '../content/Content'
5 | import { Footer } from '../footer'
6 | import { Header } from '../header'
7 |
8 | export const Root: Component = ({ children }) => {
9 | return (
10 | <>
11 |
12 | {children}
13 |
14 |
15 |
16 |
17 |
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/hooks/common/use-is-client.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { startTransition, useEffect, useState } from 'react'
4 |
5 | export const useIsClient = () => {
6 | const [isClient, setIsClient] = useState(false)
7 |
8 | useEffect(() => {
9 | setIsClient(true)
10 | }, [])
11 | return isClient
12 | }
13 |
14 | export const useIsClientTransition = () => {
15 | const [isClient, setIsClient] = useState(false)
16 |
17 | useEffect(() => {
18 | startTransition(() => {
19 | setIsClient(true)
20 | })
21 | }, [])
22 | return isClient
23 | }
24 |
--------------------------------------------------------------------------------
/storybook/src/main.tsx:
--------------------------------------------------------------------------------
1 | import './index.css'
2 | import './markdown.css'
3 | import '../../src/styles/index.css'
4 |
5 | import { LazyMotion } from 'motion/react'
6 | import ReactDOM from 'react-dom/client'
7 | import { RouterProvider } from 'react-router-dom'
8 |
9 | import { routes } from './router'
10 |
11 | const load = () => import('motion/react').then((res) => res.domMax)
12 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
13 |
14 |
15 | ,
16 | )
17 |
--------------------------------------------------------------------------------
/src/app/(app)/notes/redirect.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter } from 'next/navigation'
4 | import { useLayoutEffect } from 'react'
5 |
6 | import { routeBuilder, Routes } from '~/lib/route-builder'
7 |
8 | import FullPageLoading from '../friends/loading'
9 |
10 | export default function NodeRedirect({ nid }: { nid: number }) {
11 | const router = useRouter()
12 | useLayoutEffect(() => {
13 | router.replace(
14 | routeBuilder(Routes.Note, {
15 | id: nid,
16 | }),
17 | )
18 | }, [nid])
19 | return
20 | }
21 |
--------------------------------------------------------------------------------
/src/hooks/common/use-disclosure.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react'
2 |
3 | export const useDisclosure = () => {
4 | const [isOpen, setIsOpen] = useState(false)
5 | const onClose = useCallback(() => setIsOpen(false), [])
6 | const onOpen = useCallback(() => setIsOpen(true), [])
7 | const onToggle = useCallback(() => setIsOpen((isOpen) => !isOpen), [])
8 | const onOpenChange = useCallback((open: boolean) => setIsOpen(open), [])
9 | return {
10 | isOpen,
11 | onClose,
12 | onOpen,
13 | onToggle,
14 | onOpenChange,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/common/use-uncontrolled-input.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useLayoutEffect, useRef } from 'react'
2 |
3 | export const useUncontrolledInput = <
4 | T extends { value: string } = HTMLInputElement,
5 | >(
6 | initialValue?: string,
7 | ) => {
8 | const ref = useRef(null)
9 |
10 | useLayoutEffect(() => {
11 | if (initialValue) {
12 | ref.current && (ref.current.value = initialValue)
13 | }
14 | }, [])
15 |
16 | return [
17 | ref.current?.value,
18 | useCallback(() => ref.current?.value, []),
19 | ref,
20 | ] as const
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { NotFound404 } from '~/components/common/404'
2 | import { StyledButton } from '~/components/ui/button'
3 | import { sansFont } from '~/lib/fonts'
4 |
5 | export default () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | 返回首页
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/modules/comment/CommentMarkdown.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react'
2 |
3 | import { Markdown, RuleType } from '~/components/ui/markdown'
4 |
5 | const disabledTypes = [
6 | RuleType.footnote,
7 | RuleType.footnoteReference,
8 | RuleType.htmlComment,
9 | RuleType.htmlSelfClosing,
10 | RuleType.htmlBlock,
11 | ]
12 |
13 | export const CommentMarkdown: FC<{
14 | children: string
15 | }> = ({ children }) => (
16 |
22 | )
23 |
--------------------------------------------------------------------------------
/src/lib/github.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | const gitHubEndpoint = 'https://api.github.com'
4 | const reverseProxy = '/api/gh'
5 |
6 | export const fetchGitHubApi = (path: string) => {
7 | const nextPath = path.replace(gitHubEndpoint, '')
8 |
9 | return Promise.any([
10 | fetch(gitHubEndpoint + nextPath).then((res) => {
11 | if (res.status === 403) {
12 | throw new Error('GitHub API rate limit exceeded')
13 | }
14 |
15 | return res.json()
16 | }),
17 | fetch(reverseProxy + nextPath).then((res) => res.json()),
18 | ]) as Promise
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/(app)/posts/(post-detail)/[category]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation'
2 |
3 | import { apiClient } from '~/lib/request'
4 | import { definePrerenderPage } from '~/lib/request.server'
5 |
6 | export default definePrerenderPage<{
7 | category: string
8 | }>()({
9 | fetcher({ category }) {
10 | return apiClient.post.getFullUrl(category)
11 | },
12 | Component({ data }) {
13 | redirect(`/posts${data.path}`)
14 |
15 | return (
16 |
17 | 正在重定向到
{`/posts${data.path}`}
18 |
19 | )
20 | },
21 | })
22 |
--------------------------------------------------------------------------------
/src/components/common/ClientOnly.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { ReactNode } from 'react'
4 |
5 | import { useIsClient } from '~/hooks/common/use-is-client'
6 |
7 | export const ClientOnly: Component<{
8 | fallback?: ReactNode
9 | }> = (props) => {
10 | const isClient = useIsClient()
11 | if (!isClient) return props.fallback ?? null
12 | return <>{props.children}>
13 | }
14 |
15 | export const withClientOnly =
16 | (Component: Component
) =>
17 | (props: P) => (
18 |
19 |
20 |
21 | )
22 |
--------------------------------------------------------------------------------
/src/components/ui/label/LabelContext.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { createContext } from 'react'
3 |
4 | const LabelPropsContext = createContext<{
5 | className?: string
6 | }>({})
7 |
8 | export const useLabelPropsContext = () => React.useContext(LabelPropsContext)
9 |
10 | export const LabelProvider: React.FC<
11 | React.ContextType & React.PropsWithChildren
12 | > = ({ children, ...props }) => {
13 | return (
14 |
15 | {children}
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/common/BizErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react'
2 |
3 | export const BizErrorPage: FC<{
4 | bizMessage: string
5 | status: number
6 | }> = ({ bizMessage, status }) => {
7 | return (
8 |
9 |
10 |
数据接口请求出现错误
11 |
12 | HTTP Status: {status}
13 |
14 |
15 | 错误信息:{bizMessage}
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/modules/shared/LoadMoreIndicator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useInView } from 'react-intersection-observer'
4 |
5 | import { Loading } from '~/components/ui/loading'
6 |
7 | export const LoadMoreIndicator: Component<{
8 | onLoading: () => void
9 | }> = ({ onLoading, children, className }) => {
10 | const { ref } = useInView({
11 | rootMargin: '1px',
12 | onChange(inView) {
13 | if (inView) onLoading()
14 | },
15 | })
16 | return (
17 |
18 | {children ?? }
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/ui/markdown/renderers/video.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react'
2 |
3 | import { clsxm } from '~/lib/helper'
4 |
5 | import { VideoPlayer } from '../../media/VideoPlayer'
6 |
7 | export const Video: FC<
8 | Omit, 'src'> & { src?: string }
9 | > = (props) => {
10 | if (!props.src) return null
11 | return (
12 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/ui/modal/stacked/constants.ts:
--------------------------------------------------------------------------------
1 | import type { MotionProps, TargetAndTransition } from 'motion/react'
2 |
3 | import { microReboundPreset } from '~/constants/spring'
4 |
5 | const enterStyle: TargetAndTransition = {
6 | scale: 1,
7 | opacity: 1,
8 | }
9 | const initialStyle: TargetAndTransition = {
10 | scale: 0.96,
11 | opacity: 0,
12 | }
13 |
14 | export const modalMontionConfig: MotionProps = {
15 | initial: initialStyle,
16 | animate: enterStyle,
17 | exit: initialStyle,
18 | transition: microReboundPreset,
19 | }
20 |
21 | export const MODAL_STACK_Z_INDEX = 100
22 |
--------------------------------------------------------------------------------
/src/lib/biz.ts:
--------------------------------------------------------------------------------
1 | import { getCurrentNoteData } from '~/providers/note/CurrentNoteDataProvider'
2 | import { getGlobalCurrentPostData } from '~/providers/post/CurrentPostDataProvider'
3 |
4 | import { isServerSide } from './env'
5 |
6 | export const getCurrentPageId = () => {
7 | if (isServerSide) return
8 | const { pathname } = window.location
9 |
10 | if (pathname.startsWith('/notes/')) {
11 | const noteId = getCurrentNoteData()
12 |
13 | return noteId?.data.id
14 | }
15 |
16 | if (pathname.startsWith('/posts/')) {
17 | return getGlobalCurrentPostData().id
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/layout/header/hooks.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 |
5 | import { useSetHeaderShouldShowBg } from '~/components/layout/header/internal/hooks'
6 |
7 | export const useHideHeaderBgInRoute = () => {
8 | const setter = useSetHeaderShouldShowBg()
9 | useEffect(() => {
10 | setter(false)
11 | return () => {
12 | setter(true)
13 | }
14 | }, [])
15 | }
16 |
17 | export const HeaderHideBg = () => {
18 | useHideHeaderBgInRoute()
19 | return null
20 | }
21 |
22 | export { useSetHeaderMetaInfo } from '~/components/layout/header/internal/hooks'
23 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/note-editing/NoteNid.tsx:
--------------------------------------------------------------------------------
1 | import { useAggregationSelector } from '~/providers/root/aggregation-data-provider'
2 |
3 | import { useNoteModelSingleFieldAtom } from './data-provider'
4 |
5 | export const NoteNid = () => {
6 | const webUrl = location.origin
7 |
8 | const [nid] = useNoteModelSingleFieldAtom('nid')
9 |
10 | const latestNid = useAggregationSelector((s) => s.latestNoteId)
11 |
12 | return (
13 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/modules/say/Button.tsx:
--------------------------------------------------------------------------------
1 | import { MotionButtonBase } from '~/components/ui/button'
2 | import { clsxm } from '~/lib/helper'
3 |
4 | import { useSayModal } from './hooks'
5 |
6 | export const CreateSayButton: Component = ({ className }) => {
7 | const present = useSayModal()
8 | return (
9 | present()}
11 | className={clsxm(
12 | 'flex size-8 duration-200 center hover:text-accent',
13 | className,
14 | )}
15 | >
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/ui/portal/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { FC, PropsWithChildren } from 'react'
4 | import { createPortal } from 'react-dom'
5 |
6 | import { useIsClient } from '~/hooks/common/use-is-client'
7 |
8 | import { useRootPortal } from './provider'
9 |
10 | export const RootPortal: FC<
11 | {
12 | to?: HTMLElement
13 | } & PropsWithChildren
14 | > = (props) => {
15 | const isClient = useIsClient()
16 | const to = useRootPortal()
17 | if (!isClient) {
18 | return null
19 | }
20 |
21 | return createPortal(props.children, props.to || to || document.body)
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/code-editor/index.demo.tsx:
--------------------------------------------------------------------------------
1 | import type { DocumentComponent } from 'storybook/typings'
2 |
3 | import { CodeEditor } from './CodeEditor'
4 |
5 | export const CodeEditorDemo: DocumentComponent = () => {
6 | return (
7 |
8 | `const a = ${Math.random()};\n`)
12 | .join('')}
13 | language="javascript"
14 | />
15 |
16 | )
17 | }
18 |
19 | CodeEditorDemo.meta = {
20 | title: 'CodeEditor',
21 | }
22 |
--------------------------------------------------------------------------------
/src/lib/atom.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/rules-of-hooks */
2 | /* eslint-disable react-hooks/exhaustive-deps */
3 | import type { Atom } from 'jotai'
4 | import { useAtomValue } from 'jotai'
5 | import { selectAtom } from 'jotai/utils'
6 | import { useCallback } from 'react'
7 |
8 | export const createAtomSelector = (atom: Atom) => {
9 | const hook = (selector: (a: T) => R, deps: any[] = []) =>
10 | useAtomValue(
11 | selectAtom(
12 | atom,
13 | useCallback((a) => selector(a as T), deps),
14 | ),
15 | )
16 |
17 | hook.__atom = atom
18 | return hook
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/icons/platform/XIcon.tsx:
--------------------------------------------------------------------------------
1 | export const XIcon = () => {
2 | return (
3 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/lib/upload.ts:
--------------------------------------------------------------------------------
1 | import { apiClient } from './request'
2 |
3 | export enum FileTypeEnum {
4 | Icon = 'icon',
5 | Photo = 'photo',
6 | File = 'file',
7 | Avatar = 'avatar',
8 | }
9 | export const uploadFileToServer = (type: FileTypeEnum, file: File) => {
10 | const formData = new FormData()
11 | formData.append('file', file)
12 |
13 | return apiClient.proxy.objects.upload.post<{
14 | name: string
15 | url: string
16 | }>({
17 | data: formData,
18 | headers: {
19 | 'Content-Type': 'multipart/form-data',
20 | },
21 | params: {
22 | type,
23 | },
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/api/music/tencent/route.ts:
--------------------------------------------------------------------------------
1 | import type { NextRequest } from 'next/server'
2 | import { NextResponse } from 'next/server'
3 |
4 | export const POST = async (req: NextRequest) => {
5 | const requestBody = await req.json()
6 | const { songId } = requestBody
7 |
8 | const response = await fetch(
9 | `https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songmid=${songId}&platform=yqq&format=json`,
10 | {
11 | method: 'GET',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | },
15 | },
16 | )
17 |
18 | return NextResponse.json(await response.json())
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/modules/comment/index.ts:
--------------------------------------------------------------------------------
1 | import dynamic from 'next/dynamic'
2 | import { createElement } from 'react'
3 |
4 | import { Loading } from '~/components/ui/loading'
5 |
6 | export const CommentsLazy = dynamic(
7 | () => import('./Comments').then((mod) => mod.Comments),
8 | { ssr: false },
9 | )
10 | export const CommentBoxRootLazy = dynamic(
11 | () => import('./CommentBox').then((mod) => mod.CommentBoxRoot),
12 | {
13 | ssr: false,
14 | loading: () => createElement(Loading, { useDefaultLoadingText: true }),
15 | },
16 | )
17 |
18 | export { CommentAreaRootLazy } from './CommentRootLazy'
19 |
--------------------------------------------------------------------------------
/src/app/(app)/common/deleted/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 |
3 | import { NormalContainer } from '~/components/layout/container/Normal'
4 | import { StyledButton } from '~/components/ui/button'
5 |
6 | export default async function PageDeleted() {
7 | return (
8 |
9 |
10 |
此页面已被删除
11 |
12 |
13 | 返回首页
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/ui/markdown/renderers/index.module.css:
--------------------------------------------------------------------------------
1 | .link {
2 | display: inline-block;
3 | position: relative;
4 |
5 | a {
6 | cursor: alias;
7 | overflow: hidden;
8 | position: relative;
9 | color: var(--a);
10 | }
11 |
12 | a::after {
13 | content: '';
14 | position: absolute;
15 | bottom: -1.9px;
16 | height: 1px;
17 | background-color: currentColor;
18 | width: 0;
19 | transform: translateX(-50%);
20 | left: 50%;
21 | text-align: center;
22 | transition: width 0.5s ease-in-out;
23 | }
24 |
25 | a:hover::after {
26 | width: 100%;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/modules/subscribe/SubscribeTextButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { FC, PropsWithChildren } from 'react'
4 |
5 | import { useIsEnableSubscribe, usePresentSubscribeModal } from './hooks'
6 |
7 | export const SubscribeTextButton: FC = ({ children }) => {
8 | const canSubscribe = useIsEnableSubscribe()
9 | const { present } = usePresentSubscribeModal()
10 |
11 | if (!canSubscribe) {
12 | return null
13 | }
14 |
15 | return (
16 | <>
17 |
18 |
19 |
20 | {children}
21 | >
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ui/dialog/DialogOverlay.tsx:
--------------------------------------------------------------------------------
1 | import * as Dialog from '@radix-ui/react-dialog'
2 | import { m } from 'motion/react'
3 |
4 | export const DialogOverlay = ({
5 | onClick,
6 | zIndex,
7 | }: {
8 | onClick?: () => void
9 | zIndex?: number
10 | }) => {
11 | return (
12 |
13 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/common/HydrationEndDetector.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { atom } from 'jotai'
4 | import { useEffect } from 'react'
5 |
6 | import { jotaiStore } from '~/lib/store'
7 |
8 | const hydrateEndAtom = atom(false)
9 |
10 | /**
11 | * To skip page transition when first load, improve LCP
12 | */
13 | export const HydrationEndDetector = () => {
14 | useEffect(() => {
15 | // waiting for hydration end and animation end
16 | setTimeout(() => {
17 | jotaiStore.set(hydrateEndAtom, true)
18 | }, 2000)
19 | }, [])
20 | return null
21 | }
22 |
23 | export const isHydrationEnded = () => jotaiStore.get(hydrateEndAtom)
24 |
--------------------------------------------------------------------------------
/src/components/icons/mermaid.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function SimpleIconsMermaid(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { clsxm } from '~/lib/helper'
2 |
3 | export const Skeleton: Component = ({ className }) => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
Loading...
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/helper.ts:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export const clsxm = (...args: any[]) => {
5 | return twMerge(clsx(args))
6 | }
7 |
8 | export const escapeHTMLTag = (html: string) => {
9 | const lt = //g,
11 | ap = /'/g,
12 | ic = /"/g
13 | return html
14 | .toString()
15 | .replaceAll(lt, '<')
16 | .replaceAll(gt, '>')
17 | .replaceAll(ap, ''')
18 | .replaceAll(ic, '"')
19 | }
20 |
21 | export const safeJsonParse = (str: string) => {
22 | try {
23 | return JSON.parse(str)
24 | } catch {
25 | return null
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # next.js
10 | /.next/
11 | /out/
12 |
13 | # production
14 | /build
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .pnpm-debug.log*
25 |
26 | # local env files
27 | .env*.local
28 |
29 | # vercel
30 | .vercel
31 |
32 | # typescript
33 | *.tsbuildinfo
34 | next-env.d.ts
35 |
36 | .env
37 | .idea
38 |
39 | .vscode/sftp.json
40 |
41 | src/app/dev
42 |
43 | /public/static
44 | /public/pusher-sw.js
--------------------------------------------------------------------------------
/src/components/icons/platform/MicrosoftIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function LogosMicrosoftIcon(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/biz/use-refetch-data.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | import { EmitKeyMap } from '~/constants/keys'
4 | import { useForceUpdate } from '~/hooks/common/use-force-update'
5 |
6 | export const useRefetchData = (refetchFn: () => Promise) => {
7 | const [forceUpdate, key] = useForceUpdate()
8 | useEffect(() => {
9 | const handler = () => {
10 | refetchFn().then(forceUpdate)
11 | }
12 | window.addEventListener(EmitKeyMap.Refetch, handler)
13 | return () => {
14 | window.removeEventListener(EmitKeyMap.Refetch, handler)
15 | }
16 | }, [forceUpdate, refetchFn])
17 |
18 | return [key] as const
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/request.shared.ts:
--------------------------------------------------------------------------------
1 | import { RequestError } from '@mx-space/api-client'
2 | import type { FetchError } from 'ofetch'
3 |
4 | export const getErrorMessageFromRequestError = (error: RequestError) => {
5 | if (!(error instanceof RequestError)) return (error as Error).message
6 | const fetchError = error.raw as FetchError
7 | const messagesOrMessage = fetchError.response?._data?.message
8 | const bizMessage =
9 | typeof messagesOrMessage === 'string'
10 | ? messagesOrMessage
11 | : Array.isArray(messagesOrMessage)
12 | ? messagesOrMessage[0]
13 | : undefined
14 |
15 | return bizMessage || fetchError.message
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/modules/post/PostPinIcon.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 |
5 | import { apiClient } from '~/lib/request'
6 |
7 | import { PinIconToggle } from '../shared/PinIconToggle'
8 |
9 | export const PostPinIcon = ({ pin, id }: { pin: boolean; id: string }) => {
10 | const [pinState, setPinState] = useState(pin)
11 | return (
12 | {
14 | await apiClient.post.proxy(id).patch({
15 | data: {
16 | pin: nextPin,
17 | },
18 | })
19 | setPinState(nextPin)
20 | }}
21 | pin={pinState}
22 | />
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/ui/transition/BottomToUpTransitionView.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { softBouncePreset, Spring } from '~/constants/spring'
4 |
5 | import { createTransitionView } from './factor'
6 |
7 | export const BottomToUpTransitionView = createTransitionView({
8 | from: {
9 | y: 50,
10 | opacity: 0.001,
11 | },
12 | to: {
13 | y: 0,
14 | opacity: 1,
15 | },
16 | preset: softBouncePreset,
17 | })
18 |
19 | export const BottomToUpSmoothTransitionView = createTransitionView({
20 | from: {
21 | y: 50,
22 | opacity: 0.001,
23 | },
24 | to: {
25 | y: 0,
26 | opacity: 1,
27 | },
28 | preset: Spring.presets.smooth,
29 | })
30 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/writing/TitleInput.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react'
2 |
3 | import { AdvancedInput } from '~/components/ui/input'
4 |
5 | import { useBaseWritingAtom } from './BaseWritingProvider'
6 |
7 | export const TitleInput: FC<{
8 | label?: string
9 | }> = ({ label }) => {
10 | const [title, setTitle] = useBaseWritingAtom('title')
11 |
12 | return (
13 | setTitle(e.target.value)}
21 | />
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/modules/shared/EmojiPicker.tsx:
--------------------------------------------------------------------------------
1 | import EmojiPicker$, { EmojiStyle, Theme } from 'emoji-picker-react'
2 | import type { FC } from 'react'
3 | import { memo } from 'react'
4 |
5 | import { useIsDark } from '~/hooks/common/use-is-dark'
6 |
7 | export const EmojiPicker: FC<{
8 | onEmojiSelect: (emoji: string) => void
9 | }> = memo(({ onEmojiSelect }) => {
10 | const isDark = useIsDark()
11 | return (
12 | {
15 | onEmojiSelect(e.emoji)
16 | }}
17 | emojiStyle={EmojiStyle.NATIVE}
18 | />
19 | )
20 | })
21 | EmojiPicker.displayName = 'EmojiPicker'
22 |
--------------------------------------------------------------------------------
/src/components/ui/button/MotionButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { HTMLMotionProps } from 'motion/react'
4 | import { m } from 'motion/react'
5 | import { forwardRef } from 'react'
6 |
7 | export const MotionButtonBase = forwardRef<
8 | HTMLButtonElement,
9 | HTMLMotionProps<'button'>
10 | >(({ children, ...rest }, ref) => {
11 | return (
12 |
20 | {children}
21 |
22 | )
23 | })
24 |
25 | MotionButtonBase.displayName = 'MotionButtonBase'
26 |
--------------------------------------------------------------------------------
/src/components/ui/viewport/OnlyDesktop.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useAtomValue } from 'jotai'
4 | import { selectAtom } from 'jotai/utils'
5 | import type { ExtractAtomValue } from 'jotai/vanilla'
6 |
7 | import { viewportAtom } from '~/atoms/viewport'
8 | import { useIsClient } from '~/hooks/common/use-is-client'
9 |
10 | const selector = (v: ExtractAtomValue) => v.lg && v.w !== 0
11 | export const OnlyDesktop: Component = ({ children }) => {
12 | const isClient = useIsClient()
13 |
14 | const isLg = useAtomValue(selectAtom(viewportAtom, selector))
15 | if (!isClient) return null
16 |
17 | if (!isLg) return null
18 |
19 | return children
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/icons/platform/NpmIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function VscodeIconsFileTypeNpm(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icons/return.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function KeyboardReturnRounded(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/scripts/delete-ci-build-artifact.mjs:
--------------------------------------------------------------------------------
1 | import { $fetch } from 'ofetch'
2 |
3 | const gh_token = process.env.GH_TOKEN
4 |
5 | const namespace = 'innei-dev/Shiroi'
6 |
7 | const myFetch = $fetch.create({
8 | headers: {
9 | Authorization: `Bearer ${gh_token}`,
10 | },
11 | })
12 | const data = await myFetch(
13 | `https://api.github.com/repos/${namespace}/actions/artifacts`,
14 | )
15 |
16 | data.artifacts.forEach(async (artifact) => {
17 | if (artifact.name === 'artifact') {
18 | console.log('deleting', artifact.id)
19 | await myFetch(
20 | `https://api.github.com/repos/${namespace}/actions/artifacts/${artifact.id}`,
21 | { method: 'DELETE' },
22 | )
23 | }
24 | })
25 |
--------------------------------------------------------------------------------
/src/routes/notes/topics/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation'
2 | import { useLayoutEffect } from 'react'
3 |
4 | import { useResolveAdminUrl } from '~/atoms/hooks/url'
5 | import { defineRouteConfig } from '~/components/modules/dashboard/utils/helper'
6 |
7 | export const config = defineRouteConfig({
8 | title: '话题',
9 | icon: ,
10 | priority: 3,
11 | })
12 | export function Component() {
13 | const toAdminUrl = useResolveAdminUrl()
14 | const router = useRouter()
15 | useLayoutEffect(() => {
16 | window.open(toAdminUrl('#/notes/topic'), '_blank')
17 | router.back()
18 | }, [router, toAdminUrl])
19 | return null
20 | }
21 |
--------------------------------------------------------------------------------
/src/routes/posts/category/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation'
2 | import { useLayoutEffect } from 'react'
3 |
4 | import { useResolveAdminUrl } from '~/atoms/hooks/url'
5 | import { defineRouteConfig } from '~/components/modules/dashboard/utils/helper'
6 |
7 | export const config = defineRouteConfig({
8 | priority: 3,
9 | title: '分类',
10 | icon: ,
11 | })
12 | export function Component() {
13 | const toAdminUrl = useResolveAdminUrl()
14 | const router = useRouter()
15 | useLayoutEffect(() => {
16 | window.open(toAdminUrl('#/posts/category'), '_blank')
17 | router.back()
18 | }, [router, toAdminUrl])
19 | return null
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/(app)/posts/(post-detail)/[category]/[slug]/api.tsx:
--------------------------------------------------------------------------------
1 | import { cache } from 'react'
2 |
3 | import { attachServerFetch } from '~/lib/attach-fetch'
4 | import { getQueryClient } from '~/lib/query-client.server'
5 | import { requestErrorHandler } from '~/lib/request.server'
6 | import { queries } from '~/queries/definition'
7 |
8 | export interface PageParams {
9 | category: string
10 | slug: string
11 | }
12 | export const getData = cache(async (params: PageParams) => {
13 | const { category, slug } = params
14 | attachServerFetch()
15 | const data = await getQueryClient()
16 | .fetchQuery(queries.post.bySlug(category, slug))
17 | .catch(requestErrorHandler)
18 | return data
19 | })
20 |
--------------------------------------------------------------------------------
/src/queries/definition/aggregation.ts:
--------------------------------------------------------------------------------
1 | import type { AggregateRoot } from '@mx-space/api-client'
2 | import { isServer } from '@tanstack/react-query'
3 |
4 | import { apiClient } from '~/lib/request'
5 |
6 | import { defineQuery } from '../helper'
7 |
8 | export const aggregation = {
9 | root: () =>
10 | defineQuery({
11 | queryKey: ['aggregation'],
12 | queryFn: async () =>
13 | apiClient.aggregate.getAggregateData('shiro').then(
14 | (res) =>
15 | res.$serialized as AggregateRoot & {
16 | theme: AppThemeConfig
17 | },
18 | ),
19 | gcTime: 1000 * 60 * 10,
20 | staleTime: isServer ? 1000 * 60 * 10 : undefined,
21 | }),
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/button/RoundedIconButton.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react'
2 |
3 | import { clsxm } from '~/lib/helper'
4 |
5 | import { MotionButtonBase } from './MotionButton'
6 |
7 | export const RoundedIconButton: typeof MotionButtonBase = forwardRef(
8 | ({ className, children, ...rest }, ref) => {
9 | return (
10 |
18 | {children}
19 |
20 | )
21 | },
22 | )
23 |
24 | RoundedIconButton.displayName = 'RoundedIconButton'
25 |
--------------------------------------------------------------------------------
/src/components/ui/typography/index.demo.tsx:
--------------------------------------------------------------------------------
1 | import type { DocumentComponent, DocumentPageMeta } from 'storybook/typings'
2 |
3 | import { EllipsisHorizontalTextWithTooltip } from './EllipsisWithTooltip'
4 |
5 | export const EllipsisTextWithTooltipDemo: DocumentComponent = () => {
6 | return (
7 |
8 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam
9 |
10 | )
11 | }
12 |
13 | EllipsisTextWithTooltipDemo.meta = {
14 | title: '文本溢出省略 + 提示',
15 | description: '如果文本溢出则省略,省略时伴随 Tooltip 提示',
16 | }
17 |
18 | export const metadata: DocumentPageMeta = {
19 | title: '文本溢出',
20 | description: '一个简单的处理文本溢出省略的组件',
21 | }
22 |
--------------------------------------------------------------------------------
/src/app.static.config.ts:
--------------------------------------------------------------------------------
1 | export const appStaticConfig = {
2 | ai: {
3 | summary: {
4 | enabled: true,
5 | // providers: ['openai', 'xlog'],
6 | providers: ['xlog'],
7 | },
8 | },
9 |
10 | cache: {
11 | enabled: true,
12 |
13 | ttl: {
14 | aggregation: 3600,
15 | },
16 | },
17 | }
18 |
19 | export const CDN_HOST = 'cdn.innei.ren'
20 | export const TENCENT_CDN_DOMAIN = CDN_HOST
21 |
22 | export const s3Config = {
23 | accessKeyId: process.env.S3_ACCESS_KEY as string,
24 | secretAccessKey: process.env.S3_SECRET_KEY as string,
25 | bucket: 'uploads',
26 | customDomain: 'https://object.innei.in',
27 | endpoint: `https://de7ecb0eaa0a328071255d557a6adb66.r2.cloudflarestorage.com`,
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/modules/shared/Tweet.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import type { TwitterComponents } from 'react-tweet'
3 |
4 | const components: TwitterComponents = {
5 | AvatarImg: (props) => ,
6 | MediaImg: (props) => (
7 |
14 | ),
15 | }
16 |
17 | export default async function Tweet({ id }: { id: string }) {
18 | const { Tweet: ReactTweet } = await import('react-tweet')
19 |
20 | return (
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/markdown.ts:
--------------------------------------------------------------------------------
1 | import RemoveMarkdown from 'remove-markdown'
2 |
3 | export function getSummaryFromMd(text: string): string
4 | export function getSummaryFromMd(
5 | text: string,
6 | options: { count: true; length?: number },
7 | ): { description: string; wordCount: number }
8 |
9 | export function getSummaryFromMd(
10 | text: string,
11 | options: { count?: boolean; length?: number } = {
12 | count: false,
13 | length: 150,
14 | },
15 | ) {
16 | const rawText = RemoveMarkdown(text)
17 | const description = rawText.slice(0, options.length).replaceAll(/\s/g, ' ')
18 | if (options.count) {
19 | return {
20 | description,
21 | wordCount: rawText.length,
22 | }
23 | }
24 | return description
25 | }
26 |
--------------------------------------------------------------------------------
/src/providers/root/app-feature-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { FC, PropsWithChildren } from 'react'
4 | import { createContext, useContextSelector } from 'use-context-selector'
5 |
6 | const appFeatures = {
7 | tmdb: false,
8 | }
9 | const AppFeatureContext = createContext(appFeatures)
10 | export const AppFeatureProvider: FC = ({
11 | children,
12 | ...features
13 | }) => {
14 | return (
15 |
16 | {children}
17 |
18 | )
19 | }
20 |
21 | export const useFeatureEnabled = (feature: keyof typeof appFeatures) => {
22 | return useContextSelector(AppFeatureContext, (ctx) => ctx[feature])
23 | }
24 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [innei]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: ['https://afdian.net/@Innei']
14 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/post-editing/sidebar/SummaryInput.tsx:
--------------------------------------------------------------------------------
1 | import { TextArea } from '~/components/ui/input'
2 |
3 | import { SidebarSection } from '../../writing/SidebarBase'
4 | import { usePostModelSingleFieldAtom } from '../data-provider'
5 |
6 | export const SummaryInput = () => {
7 | const [summary, setSummary] = usePostModelSingleFieldAtom('summary')
8 |
9 | return (
10 |
11 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/modules/post/PostOutdate.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import dayjs from 'dayjs'
4 |
5 | import { Banner } from '~/components/ui/banner'
6 | import { RelativeTime } from '~/components/ui/relative-time'
7 | import { useCurrentPostDataSelector } from '~/providers/post/CurrentPostDataProvider'
8 |
9 | export const PostOutdate = () => {
10 | const time = useCurrentPostDataSelector((s) => s?.modified)
11 | if (!time) {
12 | return null
13 | }
14 | return dayjs().diff(dayjs(time), 'day') > 60 ? (
15 |
16 |
17 | 这篇文章上次修改于
18 | ,可能部分内容已经不适用,如有疑问可询问作者。
19 |
20 |
21 | ) : null
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/fab/BackToTopFAB.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useViewport } from '~/atoms/hooks'
4 | import { springScrollToTop } from '~/lib/scroller'
5 | import { usePageScrollLocationSelector } from '~/providers/root/page-scroll-info-provider'
6 |
7 | import { FABPortable } from './FABContainer'
8 |
9 | export const BackToTopFAB = () => {
10 | const windowHeight = useViewport((v) => v.h)
11 | const shouldShow = usePageScrollLocationSelector(
12 | (scrollTop) => {
13 | return scrollTop > windowHeight / 5
14 | },
15 | [windowHeight],
16 | )
17 |
18 | return (
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/(app)/categories/[slug]/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | import { NormalContainer } from '~/components/layout/container/Normal'
4 |
5 | import { getData } from './api'
6 |
7 | export const dynamic = 'force-dynamic'
8 | export const generateMetadata = async (
9 | props: NextPageParams<{
10 | slug: string
11 | }>,
12 | ) => {
13 | const data = await getData(props.params).catch(() => null)
14 |
15 | if (!data) {
16 | return {}
17 | }
18 |
19 | return {
20 | title: `分类 · ${data.name}`,
21 | } satisfies Metadata
22 | }
23 | export default async function Layout(
24 | props: NextPageParams<{
25 | slug: string
26 | }>,
27 | ) {
28 | return {props.children}
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/icons/user-heart.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function RiUserHeartLine(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/ui/markdown/parsers/ins.tsx:
--------------------------------------------------------------------------------
1 | import type { MarkdownToJSX } from 'markdown-to-jsx'
2 | import { Priority } from 'markdown-to-jsx'
3 | import * as React from 'react'
4 |
5 | import { parseCaptureInline, simpleInlineRegex } from '../utils/parser'
6 |
7 | const INLINE_SKIP_R =
8 | '((?:\\[.*?\\][([].*?[)\\]]|<.*?>(?:.*?<.*?>)?|`.*?`|\\\\\\1|[\\s\\S])+?)'
9 |
10 | // ++Insert++
11 | export const InsertRule: MarkdownToJSX.Rule<{
12 | children: MarkdownToJSX.ParserResult[]
13 | }> = {
14 | match: simpleInlineRegex(new RegExp(`^(\\+\\+)${INLINE_SKIP_R}\\1`)),
15 | order: Priority.MED,
16 | parse: parseCaptureInline,
17 | render(node, output, state?) {
18 | return {output(node.children, state!)}
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/icons/coffee.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function GgCoffee(props: SVGProps) {
4 | return (
5 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/layout/header/internal/BluredBackground.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import clsx from 'clsx'
4 |
5 | import { useHeaderBgOpacity } from './hooks'
6 |
7 | export const BluredBackground = () => {
8 | const headerOpacity = useHeaderBgOpacity()
9 | return (
10 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/(dashboard)/dashboard/[[...catch_all]]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useRef, useState } from 'react'
4 |
5 | import type { ClientRouter } from './router'
6 |
7 | export default function Page() {
8 | const [isReady, setIsReady] = useState(false)
9 | const RouterRef = useRef(undefined)
10 |
11 | useEffect(() => {
12 | import('./router').then(({ ClientRouter }) => {
13 | RouterRef.current = ClientRouter
14 | setIsReady(true)
15 | })
16 | }, [])
17 | if (isReady && RouterRef.current) {
18 | return
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/layout/header/internal/HeaderActionButton.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import type { JSX } from 'react'
3 | import { forwardRef } from 'react'
4 |
5 | export const HeaderActionButton = forwardRef<
6 | HTMLDivElement,
7 | JSX.IntrinsicElements['div']
8 | >(({ children, ...rest }, ref) => (
9 |
22 | {children}
23 |
24 | ))
25 |
26 | HeaderActionButton.displayName = 'HeaderActionButton'
27 |
--------------------------------------------------------------------------------
/src/components/common/PageHolder.tsx:
--------------------------------------------------------------------------------
1 | import type { UseQueryResult } from '@tanstack/react-query'
2 | import type { FC } from 'react'
3 | import { memo } from 'react'
4 |
5 | import { Loading } from '~/components/ui/loading'
6 |
7 | const LoadingComponent = () =>
8 | export const PageDataHolder = (
9 | PageImpl: FC,
10 | useQuery: () => UseQueryResult,
11 | ): FC => {
12 | const MemoedPageImpl = memo(PageImpl)
13 | MemoedPageImpl.displayName = `PageImplMemoed`
14 | const Component: FC = (props) => {
15 | const { data, isLoading } = useQuery()
16 |
17 | if (isLoading || data === null) {
18 | return
19 | }
20 | return
21 | }
22 | return Component
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/icons/platform/AppleIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function AppleIcon(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/layout/header/internal/HeaderDrawerButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { PresentSheet } from '~/components/ui/sheet'
4 | import { useIsClient } from '~/hooks/common/use-is-client'
5 |
6 | import { HeaderActionButton } from './HeaderActionButton'
7 | import { HeaderDrawerContent } from './HeaderDrawerContent'
8 |
9 | export const HeaderDrawerButton = () => {
10 | const isClient = useIsClient()
11 | const ButtonElement = (
12 |
13 |
14 |
15 | )
16 | if (!isClient) return ButtonElement
17 |
18 | return (
19 | }>
20 | {ButtonElement}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ui/code-highlighter/shiki/utils.tsx:
--------------------------------------------------------------------------------
1 | // import { bundledLanguages } from 'shiki/langs'
2 |
3 | export const parseFilenameFromAttrs = (attrs: string) => {
4 | // filename=""
5 |
6 | const match = attrs.match(/filename="([^"]+)"/)
7 | if (match) {
8 | return match[1]
9 | }
10 | return null
11 | }
12 |
13 | export const parseShouldCollapsedFromAttrs = (attrs: string) =>
14 | // collapsed
15 | attrs.includes('collapsed') || !attrs.includes('expand')
16 |
17 | // const shikiSupportLangSet = new Set(Object.keys(bundledLanguages))
18 | export const isSupportedShikiLang = (lang: string) =>
19 | // require esm error, fuck nextjs 14.12.x
20 | // @see https://github.com/vercel/next.js/issues/64434
21 | // return shikiSupportLangSet.has(lang)
22 | true
23 |
--------------------------------------------------------------------------------
/src/components/layout/footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeSwitcher } from '~/components/ui/theme-switcher'
2 |
3 | import { FooterInfo } from './FooterInfo'
4 |
5 | export const Footer = () => {
6 | return (
7 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/toast/index.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster as Sonner } from 'sonner'
2 |
3 | import { useIsDark } from '~/hooks/common/use-is-dark'
4 |
5 | type ToasterProps = React.ComponentProps
6 |
7 | export const Toaster = ({ ...props }: ToasterProps) => (
8 |
21 | )
22 |
--------------------------------------------------------------------------------
/src/app/api/bilibili/check_live/types/room.ts:
--------------------------------------------------------------------------------
1 | export interface BLRoom {
2 | code: number
3 | message: string
4 | ttl: number
5 | data: Data
6 | }
7 | interface Data {
8 | by_uids: {}
9 | by_room_ids: By_room_ids
10 | }
11 |
12 | type By_room_ids = Record
13 | export interface RoomInfo {
14 | room_id: number
15 | uid: number
16 | area_id: number
17 | live_status: number
18 | live_url: string
19 | parent_area_id: number
20 | title: string
21 | parent_area_name: string
22 | area_name: string
23 | live_time: string
24 | description: string
25 | tags: string
26 | attention: number
27 | online: number
28 | short_id: number
29 | uname: string
30 | cover: string
31 | background: string
32 | join_slide: number
33 | live_id: number
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/icons/platform/SteamIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export const SteamIcon = (props: SVGProps) => (
4 |
16 | )
17 |
--------------------------------------------------------------------------------
/src/providers/root/sentry-provider.tsx:
--------------------------------------------------------------------------------
1 | // import { useEffect } from 'react'
2 | // import type { PropsWithChildren } from 'react'
3 | //
4 | // import {
5 | // captureException,
6 | // captureUnderscoreErrorException,
7 | // } from '@sentry/nextjs'
8 | //
9 | // export const SentryProvider = ({ children }: PropsWithChildren) => {
10 | // useEffect(() => {
11 | // window.onerror = function (message, source, lineno, colno, error) {
12 | // captureException(error)
13 | // }
14 | //
15 | // window.addEventListener('unhandledrejection', (event) => {
16 | // captureUnderscoreErrorException(event.reason)
17 | // })
18 | //
19 | // return () => {
20 | // window.onerror = null
21 | // }
22 | // }, [])
23 | // return children
24 | // }
25 |
26 | export {}
27 |
--------------------------------------------------------------------------------
/src/components/modules/toc/TocAutoScroll.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 |
5 | import { escapeSelector } from '~/lib/dom'
6 |
7 | export const TocAutoScroll: Component = () => {
8 | useEffect(() => {
9 | const hash = escapeSelector(
10 | decodeURIComponent(window.location.hash.slice(1)),
11 | )
12 |
13 | if (hash) {
14 | const el = document.getElementById(hash)
15 | if (el) {
16 | el.scrollIntoView({ behavior: 'smooth', block: 'center' })
17 | }
18 | }
19 | }, [])
20 |
21 | // const isTop = usePageScrollLocationSelector((y) => y < 10)
22 |
23 | // useEffect(() => {
24 | // if (isTop) {
25 | // history.replaceState(history.state, '', `#`)
26 | // }
27 | // }, [isTop])
28 |
29 | return null
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/ui/gallery/Gallery.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | &:hover .indicator {
3 | opacity: 1;
4 | }
5 | }
6 |
7 | .container {
8 | scroll-snap-type: x mandatory;
9 | display: flex;
10 | align-items: flex-start;
11 |
12 | &::-webkit-scrollbar {
13 | display: none;
14 | }
15 | }
16 |
17 | .child {
18 | scroll-snap-align: center;
19 | flex-shrink: 0;
20 | scroll-snap-stop: always;
21 |
22 | text-align: center;
23 | }
24 |
25 | .child:last-child {
26 | margin-right: 0 !important;
27 | }
28 |
29 | .indicator {
30 | @apply absolute bottom-[24px] left-[50%] z-[1] flex rounded-[24px] bg-themed-bg_opacity px-6 py-4 opacity-0;
31 | @apply transition-opacity duration-300;
32 |
33 | transform: translateX(-50%);
34 | backdrop-filter: blur(20px) saturate(180%);
35 | }
36 |
--------------------------------------------------------------------------------
/src/hooks/common/use-single-double-click.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export function useSingleAndDoubleClick(
4 | actionSimpleClick: Function,
5 | actionDoubleClick: Function,
6 | delay = 250,
7 | ) {
8 | const [click, setClick] = useState(0)
9 |
10 | useEffect(() => {
11 | const timer = setTimeout(() => {
12 | // simple click
13 | if (click === 1) actionSimpleClick()
14 | setClick(0)
15 | }, delay)
16 |
17 | // the duration between this click and the previous one
18 | // is less than the value of delay = double-click
19 | if (click === 2) actionDoubleClick()
20 |
21 | return () => clearTimeout(timer)
22 | }, [actionDoubleClick, actionSimpleClick, click])
23 |
24 | return () => setClick((prev) => prev + 1)
25 | }
26 |
--------------------------------------------------------------------------------
/src/queries/definition/comment.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | CommentModel,
3 | CommentState,
4 | PaginateResult,
5 | } from '@mx-space/api-client'
6 |
7 | import { apiClient } from '~/lib/request'
8 |
9 | import { defineQuery } from '../helper'
10 |
11 | export const commentAdmin = {
12 | byState: (state: CommentState) =>
13 | defineQuery({
14 | queryKey: ['comment', 'admin', state.toString()],
15 | queryFn: async ({ pageParam }: any) => {
16 | const response = await apiClient.proxy.comments.get<
17 | PaginateResult
18 | >({
19 | params: {
20 | page: pageParam,
21 | size: 20,
22 | state: state | 0,
23 | },
24 | })
25 |
26 | return response
27 | },
28 | }),
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/icons/Progress.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function MaterialSymbolsProgressActivity(
4 | props: SVGProps,
5 | ) {
6 | return (
7 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/layout/container/Paper.tsx:
--------------------------------------------------------------------------------
1 | import type { JSX } from 'react'
2 |
3 | import { clsxm } from '~/lib/helper'
4 |
5 | export const Paper: Component<{
6 | as?: keyof JSX.IntrinsicElements | Component
7 | }> = ({ children, className, as: As = 'main' }) => {
8 | return (
9 |
20 | {children}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/packages/fetch/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "outDir": "./dist",
10 | "allowJs": true,
11 | "skipLibCheck": true,
12 | "strict": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "Bundler",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "baseUrl": ".",
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ],
26 | "paths": {
27 | "~/*": [
28 | "../../src/*"
29 | ]
30 | }
31 | },
32 | "include": [
33 | "./src"
34 | ],
35 | "exclude": []
36 | }
--------------------------------------------------------------------------------
/src/components/icons/platform/Twitter.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function TwitterIcon(props: SVGProps) {
4 | return (
5 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/(app)/says/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useSayListQuery } from '~/components/modules/say/hooks'
4 | import { SayMasonry } from '~/components/modules/say/SayMasonry'
5 | import { NothingFound } from '~/components/modules/shared/NothingFound'
6 | import { FullPageLoading } from '~/components/ui/loading'
7 |
8 | export default function Page() {
9 | const { data, isLoading, status } = useSayListQuery()
10 |
11 | if (isLoading || status === 'pending') {
12 | return
13 | }
14 |
15 | if (!data || data.pages.length === 0) return
16 |
17 | return (
18 |
19 |
22 |
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/modules/comment/CommentProvider.tsx:
--------------------------------------------------------------------------------
1 | import type { ReaderModel } from '@mx-space/api-client'
2 | import type { FC, PropsWithChildren } from 'react'
3 | import { createContext, useContextSelector } from 'use-context-selector'
4 |
5 | const CommentReaderMapContext = createContext>({})
6 | export const CommentProvider: FC<
7 | PropsWithChildren<{
8 | readers: Record
9 | }>
10 | > = ({ children, readers }) => {
11 | return (
12 |
13 | {children}
14 |
15 | )
16 | }
17 |
18 | export const useCommentReader = (readerId?: string) => {
19 | return useContextSelector(CommentReaderMapContext, (v) =>
20 | readerId ? v[readerId] : undefined,
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/taze.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'taze'
2 |
3 | export default defineConfig({
4 | // ignore packages from bumping
5 | exclude: ['tailwindcss', 'crossbell', '@excalidraw/excalidraw'],
6 | // fetch latest package info from registry without cache
7 | force: true,
8 | // write to package.json
9 | write: true,
10 | // run `npm install` or `yarn install` right after bumping
11 | install: true,
12 | // ignore paths for looking for package.json in monorepo
13 | ignorePaths: ['**/node_modules/**', '**/test/**'],
14 | // override with different bumping mode for each package
15 | packageMode: {
16 | typescript: 'major',
17 | },
18 | // disable checking for "overrides" package.json field
19 | depFields: {
20 | overrides: false,
21 | },
22 | recursive: true,
23 | mode: 'latest',
24 | })
25 |
--------------------------------------------------------------------------------
/src/components/ui/form/FormContext.tsx:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai'
2 | import { createContext, useContext } from 'react'
3 |
4 | import type { Field } from './types'
5 |
6 | const initialFields = atom({} as Record)
7 | export interface FormContextType {
8 | fields: typeof initialFields
9 | addField: (name: string, field: Field) => void
10 | removeField: (name: string) => void
11 | getField: (name: string) => Field | undefined
12 | getCurrentValues: () => Record
13 | }
14 |
15 | export const FormContext = createContext(null!)
16 |
17 | export const FormConfigContext = createContext<{
18 | showErrorMessage?: boolean
19 | }>(null!)
20 | export const useForm = () => {
21 | return useContext(FormContext)
22 | }
23 | export const useFormConfig = () => useContext(FormConfigContext)
24 |
--------------------------------------------------------------------------------
/cssAsPlugin.js:
--------------------------------------------------------------------------------
1 | // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856
2 | // cssAsPlugin.js
3 | const postcss = require('postcss')
4 | const postcssJs = require('postcss-js')
5 | const { readFileSync } = require('node:fs')
6 |
7 | require.extensions['.css'] = function (module, filename) {
8 | module.exports = ({ addBase, addComponents, addUtilities }) => {
9 | const css = readFileSync(filename, 'utf8')
10 | const root = postcss.parse(css)
11 | const jss = postcssJs.objectify(root)
12 |
13 | if ('@layer base' in jss) {
14 | addBase(jss['@layer base'])
15 | }
16 | if ('@layer components' in jss) {
17 | addComponents(jss['@layer components'])
18 | }
19 | if ('@layer utilities' in jss) {
20 | addUtilities(jss['@layer utilities'])
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/icons/platform/BilibiliIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export const BilibiliIcon = (props: SVGProps) => (
4 |
16 | )
17 |
--------------------------------------------------------------------------------
/src/app/api/music/netease/route.ts:
--------------------------------------------------------------------------------
1 | import type { NextRequest } from 'next/server'
2 | import { NextResponse } from 'next/server'
3 |
4 | import { weapi } from './crypto'
5 |
6 | export const POST = async (req: NextRequest) => {
7 | const requestBody = await req.json()
8 | const { songId } = requestBody
9 | const data = {
10 | c: JSON.stringify([{ id: songId, v: 0 }]),
11 | }
12 | const body: any = weapi(data)
13 | const bodyString = `params=${encodeURIComponent(body.params)}&encSecKey=${encodeURIComponent(body.encSecKey)}`
14 |
15 | const response = await fetch('http://music.163.com/weapi/v3/song/detail', {
16 | method: 'POST',
17 | headers: {
18 | 'Content-Type': 'application/x-www-form-urlencoded',
19 | },
20 | body: bodyString,
21 | })
22 |
23 | return NextResponse.json(await response.json())
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/ui/modal/stacked/context.tsx:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai'
2 | import type { FC, RefObject } from 'react'
3 | import { createContext, useContext } from 'react'
4 |
5 | import type { ModalProps } from './types'
6 |
7 | export const modalIdToPropsMap = {} as Record
8 |
9 | export type CurrentModalContentProps = ModalContentPropsInternal & {
10 | ref: RefObject
11 | }
12 |
13 | export const CurrentModalContext = createContext(
14 | null as any,
15 | )
16 |
17 | export const useCurrentModal = () => useContext(CurrentModalContext)
18 |
19 | export type ModalContentComponent = FC
20 | export type ModalContentPropsInternal = {
21 | dismiss: () => void
22 | }
23 | export const modalStackAtom = atom([] as (ModalProps & { id: string })[])
24 |
--------------------------------------------------------------------------------
/src/components/icons/platform/Telegram.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function IcBaselineTelegram(props: SVGProps) {
4 | return (
5 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/socket/util.ts:
--------------------------------------------------------------------------------
1 | import type { BusinessEvents } from '@mx-space/webhook'
2 |
3 | export const buildSocketEventType = (type: string) =>
4 | `ws_event:${type}` as const
5 |
6 | export class WsEvent extends Event {
7 | constructor(
8 | type: BusinessEvents,
9 | public data: unknown,
10 | ) {
11 | super(buildSocketEventType(type))
12 | }
13 |
14 | static on(
15 | type: BusinessEvents,
16 |
17 | cb: (data: unknown) => void,
18 | ) {
19 | const _cb = (e: any) => {
20 | cb(e.data)
21 | }
22 | document.addEventListener(buildSocketEventType(type), _cb)
23 |
24 | return () => {
25 | document.removeEventListener(buildSocketEventType(type), _cb)
26 | }
27 | }
28 |
29 | static emit(type: BusinessEvents, data: unknown) {
30 | document.dispatchEvent(new WsEvent(type, data))
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/icons/tag.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function JamTags(props: SVGProps) {
4 | return (
5 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/ui/markdown/parsers/spoiler.tsx:
--------------------------------------------------------------------------------
1 | import type { MarkdownToJSX } from 'markdown-to-jsx'
2 | import { Priority } from 'markdown-to-jsx'
3 | import * as React from 'react'
4 |
5 | import { parseCaptureInline, simpleInlineRegex } from '../utils/parser'
6 |
7 | const INLINE_SKIP_R =
8 | '((?:\\[.*?\\][([].*?[)\\]]|<.*?>(?:.*?<.*?>)?|`.*?`|\\\\\\1|\\|\\|.*?\\|\\||[\\s\\S])+?)'
9 |
10 | // ||Spoiler||
11 | export const SpoilerRule: MarkdownToJSX.Rule<{
12 | children: MarkdownToJSX.ParserResult[]
13 | }> = {
14 | match: simpleInlineRegex(new RegExp(`^(\\|\\|)${INLINE_SKIP_R}\\1`)),
15 | order: Priority.LOW,
16 | parse: parseCaptureInline,
17 | render(node, output, state?) {
18 | return (
19 |
20 | {output(node.children, state!)}
21 |
22 | )
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/shared/use-read-percent.ts:
--------------------------------------------------------------------------------
1 | import { getViewport } from '~/atoms/hooks/viewport'
2 | import { usePageScrollLocationSelector } from '~/providers/root/page-scroll-info-provider'
3 | import {
4 | useWrappedElementPosition,
5 | useWrappedElementSize,
6 | } from '~/providers/shared/WrappedElementProvider'
7 |
8 | export const useReadPercent = () => {
9 | const { y } = useWrappedElementPosition()
10 | const { h } = useWrappedElementSize()
11 | const readPercent = usePageScrollLocationSelector(
12 | (scrollTop) => {
13 | const winHeight = getViewport().h
14 | const deltaHeight = Math.min(scrollTop, winHeight)
15 |
16 | return (
17 | Math.floor(
18 | Math.min(Math.max(0, ((scrollTop - y + deltaHeight) / h) * 100), 100),
19 | ) || 0
20 | )
21 | },
22 | [y, h],
23 | )
24 | return readPercent
25 | }
26 |
--------------------------------------------------------------------------------
/src/styles/sonner.css:
--------------------------------------------------------------------------------
1 | /* sonner */
2 | [data-sonner-toast] {
3 | &[data-type='success']::before,
4 | &[data-type='error']::before,
5 | &[data-type='info']::before,
6 | &[data-type='warning']::before {
7 | content: '';
8 | border-radius: var(--border-radius);
9 |
10 | @apply pointer-events-none absolute inset-0 z-[-1] !transform-none opacity-10 dark:opacity-20;
11 | }
12 |
13 | &[data-type='success']::before {
14 | background: linear-gradient(to left, #56b4d3, #348f50);
15 | }
16 |
17 | &[data-type='error']::before {
18 | background: linear-gradient(to right, #ee9ca7, #ffdde1);
19 | }
20 |
21 | &[data-type='info']::before {
22 | background: linear-gradient(to right, #64a7d5, #6dd5fa, #c5eef4);
23 | }
24 |
25 | &[data-type='warning']::before {
26 | background: linear-gradient(to right, #f2994a, #f2c94c);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/scripts/download-latest-ci-build-artifact.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # 使用环境变量 GH_TOKEN
5 | curl_response=$(curl -L -H "Accept: application/vnd.github+json" \
6 | -H "Authorization: Bearer $GH_TOKEN" \
7 | "https://api.github.com/repos/innei/shiro/actions/artifacts?per_page=5&page=1")
8 | download_url=$(echo $curl_response | jq -r '.artifacts[] | select(.name == "artifact") | .archive_download_url')
9 |
10 | if [ -z "$download_url" ] || [ "$download_url" == "null" ]; then
11 | echo "没有找到 URL 或发生了错误。"
12 | exit 1
13 | else
14 | echo "找到的 URL: $download_url"
15 | # 此处可以添加用于下载文件的命令,例如:
16 | # curl -L "$download_url" -o desired_filename.zip
17 | fi
18 |
19 | # 使用环境变量 GH_TOKEN
20 | curl -L -H "Authorization: Bearer $GH_TOKEN" "$download_url" -o build.zip
21 |
22 | [ ! -d "shiro" ] && mkdir shiro
23 | unzip build.zip
24 | unzip -o release.zip 'standalone/*' -d shiro
25 |
--------------------------------------------------------------------------------
/src/components/ui/label/Label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from '@radix-ui/react-label'
2 | import * as React from 'react'
3 |
4 | import { clsxm } from '~/lib/helper'
5 |
6 | import { useLabelPropsContext } from './LabelContext'
7 |
8 | export const Label = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => {
12 | const propsCtx = useLabelPropsContext()
13 |
14 | return (
15 |
24 | )
25 | })
26 | Label.displayName = LabelPrimitive.Root.displayName
27 |
--------------------------------------------------------------------------------
/plugins/tw-css-plugin.js:
--------------------------------------------------------------------------------
1 | // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856
2 | // cssAsPlugin.js
3 | const postcss = require('postcss')
4 | const postcssJs = require('postcss-js')
5 | const { readFileSync } = require('node:fs')
6 |
7 | require.extensions['.css'] = function (module, filename) {
8 | const cssAsPlugin = ({ addBase, addComponents, addUtilities }) => {
9 | const css = readFileSync(filename, 'utf8')
10 | const root = postcss.parse(css)
11 | const jss = postcssJs.objectify(root)
12 |
13 | if ('@layer base' in jss) {
14 | addBase(jss['@layer base'])
15 | }
16 | if ('@layer components' in jss) {
17 | addComponents(jss['@layer components'])
18 | }
19 | if ('@layer utilities' in jss) {
20 | addUtilities(jss['@layer utilities'])
21 | }
22 | }
23 | module.exports = cssAsPlugin
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/layout/header/internal/UserAuthFromIcon.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { useSessionReader } from '~/atoms/hooks/reader'
6 | import { getStrategyIconComponent } from '~/components/ui/user/UserAuthStrategyIcon'
7 | import { clsxm } from '~/lib/helper'
8 |
9 | export const UserAuthFromIcon: Component = ({ className }) => {
10 | const session = useSessionReader()
11 | const provider = session?.provider
12 | const StrategyIcon = provider && getStrategyIconComponent(provider)
13 |
14 | if (!StrategyIcon) {
15 | return null
16 | }
17 | return (
18 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/constants/language.ts:
--------------------------------------------------------------------------------
1 | export const LanguageToColorMap = {
2 | typescript: '#2b7489',
3 | javascript: '#f1e05a',
4 | html: '#e34c26',
5 | java: '#b07219',
6 | go: '#00add8',
7 | vue: '#2c3e50',
8 | css: '#563d7c',
9 | yaml: '#cb171e',
10 | json: '#292929',
11 | markdown: '#083fa1',
12 | csharp: '#178600',
13 | 'c#': '#178600',
14 | c: '#555555',
15 | cpp: '#f34b7d',
16 | 'c++': '#f34b7d',
17 | python: '#3572a5',
18 | lua: '#000080',
19 | vimscript: '#199f4b',
20 | shell: '#89e051',
21 | dockerfile: '#384d54',
22 | ruby: '#701516',
23 | php: '#4f5d95',
24 | lisp: '#3fb68b',
25 | kotlin: '#F18E33',
26 | rust: '#dea584',
27 | dart: '#00B4AB',
28 | swift: '#ffac45',
29 | 'objective-c': '#438eff',
30 | 'objective-c++': '#6866fb',
31 | r: '#198ce7',
32 | matlab: '#e16737',
33 | scala: '#c22d40',
34 | sql: '#e38c00',
35 | perl: '#0298c3',
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ui/media/VolumeSlider.tsx:
--------------------------------------------------------------------------------
1 | import * as Slider from '@radix-ui/react-slider'
2 | import type { FC } from 'react'
3 |
4 | export const VolumeSlider: FC<{
5 | volume: number
6 | onVolumeChange: (volume: number) => void
7 | }> = ({ onVolumeChange, volume }) => (
8 | {
15 | onVolumeChange?.(values[0]!)
16 | }}
17 | >
18 |
19 |
20 |
21 |
25 |
26 | )
27 |
--------------------------------------------------------------------------------
/src/components/icons/platform/BlueskyIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function BlueskyIcon(props: SVGProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/icons/ParkOutlineTopicIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function IconParkOutlineTopic(props: SVGProps) {
4 | return (
5 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/katex/index.tsx:
--------------------------------------------------------------------------------
1 | import katex from 'katex'
2 | import type { FC } from 'react'
3 | import { useMemo } from 'react'
4 |
5 | type KateXProps = {
6 | children: string
7 | mode?: string // If `display` the math will be rendered in display mode. Otherwise the math will be rendered in inline mode.
8 | }
9 |
10 | export const KateX: FC = (props) => {
11 | const { children, mode } = props
12 |
13 | const displayMode = mode === 'display'
14 |
15 | const throwOnError = false // render unsupported commands as text instead of throwing a `ParseError`
16 |
17 | return (
18 | ({
21 | __html: katex.renderToString(children, {
22 | displayMode,
23 | throwOnError,
24 | }),
25 | }),
26 | [children, displayMode, throwOnError],
27 | )}
28 | />
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/icons/user-arrow-left.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGAttributes } from 'react'
2 |
3 | export function UserArrowLeftIcon(props: SVGAttributes) {
4 | return (
5 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/modules/note/NoteTopicMarkdownRender.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { MarkdownToJSX } from 'markdown-to-jsx'
4 | import Markdown, { RuleType } from 'markdown-to-jsx'
5 | import type { FC } from 'react'
6 |
7 | const mdOptions: MarkdownToJSX.Options = {
8 | allowedTypes: [
9 | RuleType.text,
10 | RuleType.paragraph,
11 | RuleType.codeInline,
12 | RuleType.link,
13 | RuleType.linkMailtoDetector,
14 | RuleType.linkBareUrlDetector,
15 | RuleType.linkAngleBraceStyleDetector,
16 | RuleType.textStrikethroughed,
17 | RuleType.textEmphasized,
18 | RuleType.textBolded,
19 | RuleType.textEscaped,
20 | ],
21 | forceBlock: true,
22 | wrapper: ({ children }) => {children}
,
23 | }
24 |
25 | export const NoteTopicMarkdownRender: FC<{ children: string }> = (props) => (
26 | {props.children}
27 | )
28 |
--------------------------------------------------------------------------------
/storybook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "storybook",
3 | "type": "module",
4 | "scripts": {
5 | "build": "vite build",
6 | "dev": "vite"
7 | },
8 | "dependencies": {
9 | "@radix-ui/react-scroll-area": "1.2.9",
10 | "marked": "^15.0.12",
11 | "postcss-import": "^16.1.1",
12 | "react-error-boundary": "5.0.0",
13 | "react-router-dom": "7.5.3",
14 | "vite": "6.4.1",
15 | "vite-plugin-node-polyfills": "0.23.0",
16 | "vite-plugin-restart": "0.4.2"
17 | },
18 | "devDependencies": {
19 | "@mdx-js/react": "3.1.0",
20 | "@mdx-js/rollup": "3.1.0",
21 | "@types/lodash-es": "4.17.12",
22 | "@types/react": "^18.3.23",
23 | "@types/react-dom": "^18.3.7",
24 | "@vitejs/plugin-react": "^4.3.4",
25 | "buffer": "6.0.3",
26 | "concurrently": "^9.1.2",
27 | "dotenv": "17.0.1",
28 | "unplugin-macros": "0.17.0",
29 | "vite-tsconfig-paths": "^5.1.4"
30 | }
31 | }
--------------------------------------------------------------------------------
/src/app/(app)/notes/[id]/api.tsx:
--------------------------------------------------------------------------------
1 | import { cache } from 'react'
2 |
3 | import { attachServerFetch, getAuthFromCookie } from '~/lib/attach-fetch'
4 | import { getQueryClient } from '~/lib/query-client.server'
5 | import { requestErrorHandler } from '~/lib/request.server'
6 | import { queries } from '~/queries/definition'
7 |
8 | export const getData = cache(
9 | async (params: {
10 | id: string
11 |
12 | token?: string
13 | password?: string
14 | }) => {
15 | attachServerFetch()
16 |
17 | const { id, password, token } = params
18 |
19 | const query = queries.note.byNid(
20 | id,
21 | password,
22 | token ? `${token}` : undefined,
23 | )
24 |
25 | const data = await getQueryClient()
26 | .fetchQuery({
27 | ...query,
28 | staleTime: getAuthFromCookie() ? 0 : undefined,
29 | })
30 | .catch(requestErrorHandler)
31 | return data
32 | },
33 | )
34 |
--------------------------------------------------------------------------------
/src/components/icons/clock.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function MdiClockTimeThreeOutline(props: SVGProps) {
4 | return (
5 |
11 | )
12 | }
13 |
14 | export function MdiClockOutline(props: SVGProps) {
15 | return (
16 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/providers/post/CurrentPostDataProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { PostModel } from '@mx-space/api-client'
4 | import { createModelDataProvider } from 'jojoo/react'
5 |
6 | import { isClientSide, isDev } from '~/lib/env'
7 |
8 | const {
9 | ModelDataProvider,
10 | ModelDataAtomProvider,
11 | getGlobalModelData,
12 | setGlobalModelData,
13 | useModelDataSelector,
14 | } = createModelDataProvider()
15 |
16 | declare global {
17 | interface Window {
18 | getModelPostData: typeof getGlobalModelData
19 | }
20 | }
21 | if (isDev && isClientSide) window.getModelPostData = getGlobalModelData
22 |
23 | export {
24 | ModelDataAtomProvider as CurrentPostDataAtomProvider,
25 | ModelDataProvider as CurrentPostDataProvider,
26 | getGlobalModelData as getGlobalCurrentPostData,
27 | setGlobalModelData as setGlobalCurrentPostData,
28 | useModelDataSelector as useCurrentPostDataSelector,
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/layout/footer/VercelPoweredBy.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useAppConfigSelector } from '~/providers/root/aggregation-data-provider'
4 |
5 | const isVercelEnv = !!process.env.NEXT_PUBLIC_VERCEL_ENV
6 | export const VercelPoweredBy = () => {
7 | const isSettingToDisplay = useAppConfigSelector(
8 | (s) => s.poweredBy?.vercel || false,
9 | )
10 |
11 | const shouldDisplay = isVercelEnv && isSettingToDisplay
12 |
13 | if (!shouldDisplay) {
14 | return null
15 | }
16 | return (
17 |
{
23 | window.open('https://vercel.com/?utm_source=innei&utm_campaign=oss')
24 | }}
25 | />
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/spinner/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { forwardRef } from 'react'
3 |
4 | export const Spinner = forwardRef<
5 | HTMLDivElement,
6 | {
7 | size?: number
8 | className?: string
9 | }
10 | >(({ className, size }, ref) => {
11 | return (
12 |
21 | )
22 | })
23 |
24 | Spinner.displayName = 'Spinner'
25 |
26 | export const AbsoluteCenterSpinner: Component = ({ children, className }) => {
27 | return (
28 |
34 |
35 | {children}
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/providers/page/CurrentPageDataProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { PageModel } from '@mx-space/api-client'
4 | import { createModelDataProvider } from 'jojoo/react'
5 |
6 | import { isClientSide, isDev } from '~/lib/env'
7 |
8 | const {
9 | ModelDataProvider,
10 | getGlobalModelData: getModelData,
11 | setGlobalModelData: setModelData,
12 | useModelDataSelector,
13 | ModelDataAtomProvider,
14 | } = createModelDataProvider()
15 |
16 | declare global {
17 | interface Window {
18 | getCurrentPageData: typeof getModelData
19 | }
20 | }
21 |
22 | if (isDev && isClientSide) window.getCurrentPageData = getModelData
23 |
24 | export {
25 | ModelDataAtomProvider as CurrentPageDataAtomProvider,
26 | ModelDataProvider as CurrentPageDataProvider,
27 | getModelData as getCurrentPageData,
28 | setModelData as setCurrentPageData,
29 | useModelDataSelector as useCurrentPageDataSelector,
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/ui/rich-link/SocialSourceLink.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react'
2 |
3 | import { Favicon } from './Favicon'
4 |
5 | const prefixToUrlMap = {
6 | GH: 'https://github.com/',
7 | TW: 'https://twitter.com/',
8 | TG: 'https://t.me/',
9 | ZH: 'https://www.zhihu.com/people/',
10 | }
11 |
12 | export const SocialSourceLink: FC<{
13 | source: string
14 | name: React.ReactNode
15 | href?: string
16 | }> = ({ name, source, href }) => {
17 | // @ts-ignore
18 | const urlPrefix = prefixToUrlMap[source]
19 |
20 | if (!urlPrefix) return null
21 |
22 | return (
23 |
24 |
25 |
31 | {name}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/lib/attach-fetch.ts:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 |
3 | import { cookies, headers } from 'next/headers'
4 |
5 | import PKG from '../../package.json'
6 | import { AuthKeyNames } from './cookie'
7 | import { attachFetchHeader } from './request'
8 |
9 | export const attachServerFetch = () => {
10 | const { get } = headers()
11 |
12 | const ua = get('user-agent')
13 | const ip =
14 | get('x-real-ip') ||
15 | get('x-forwarded-for') ||
16 | get('remote-addr') ||
17 | get('cf-connecting-ip')
18 |
19 | if (ip) {
20 | attachFetchHeader('x-real-ip', ip)
21 | attachFetchHeader('x-forwarded-for', ip)
22 | }
23 | attachFetchHeader(
24 | 'User-Agent',
25 | `${ua} NextJS/v${PKG.dependencies.next} ${PKG.name}/${PKG.version}`,
26 | )
27 | }
28 |
29 | export const getAuthFromCookie = () => {
30 | const cookie = cookies()
31 | const jwt = cookie.get(AuthKeyNames[0])
32 |
33 | return jwt?.value || ''
34 | }
35 |
--------------------------------------------------------------------------------
/src/lib/authjs.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient } from 'better-auth/react'
2 |
3 | import { API_URL } from '~/constants/env'
4 |
5 | export const authClient = createAuthClient({
6 | baseURL: `${API_URL}/auth`,
7 | fetchOptions: {
8 | credentials: 'include',
9 | },
10 | })
11 |
12 | export type AuthSocialProviders =
13 | | 'apple'
14 | | 'discord'
15 | | 'facebook'
16 | | 'github'
17 | | 'google'
18 | | 'microsoft'
19 | | 'spotify'
20 | | 'twitch'
21 | | 'twitter'
22 | | 'dropbox'
23 | | 'linkedin'
24 | | 'gitlab'
25 |
26 | export const getUserUrl = <
27 | T extends { handle?: string; provider: AuthSocialProviders },
28 | >(
29 | user: T,
30 | ) => {
31 | if (!user.handle) return
32 | switch (user.provider) {
33 | case 'github': {
34 | return `https://github.com/${user.handle}`
35 | }
36 | }
37 |
38 | return
39 | }
40 |
41 | const { useSession } = authClient
42 | export { useSession }
43 |
--------------------------------------------------------------------------------
/src/components/ui/form/types.ts:
--------------------------------------------------------------------------------
1 | import type { DetailedHTMLProps, InputHTMLAttributes } from 'react'
2 |
3 | export interface Rule {
4 | message: string
5 | validator: (value: T) => boolean | Promise
6 | }
7 |
8 | type ValidateStatus = 'error' | 'success'
9 | export interface Field {
10 | rules: (Rule & { status?: ValidateStatus })[]
11 | /**
12 | * `getCurrentValues` will return the transformed value
13 | * @param value field value
14 | */
15 | transform?: (value: T) => X
16 |
17 | getEl: () => HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null
18 | }
19 |
20 | export interface FormFieldBaseProps extends Pick {
21 | rules?: Rule[]
22 | name: string
23 | }
24 |
25 | export type InputFieldProps = Omit<
26 | DetailedHTMLProps, HTMLInputElement>,
27 | 'name'
28 | > &
29 | FormFieldBaseProps
30 |
--------------------------------------------------------------------------------
/src/components/layout/header/internal/HeaderWithShadow.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import clsx from 'clsx'
4 |
5 | import { useHeaderBgOpacity } from '~/components/layout/header/internal/hooks'
6 | import { usePageScrollLocationSelector } from '~/providers/root/page-scroll-info-provider'
7 |
8 | export const HeaderWithShadow: Component = ({ children }) => {
9 | const headerOpacity = useHeaderBgOpacity()
10 | const showShadow = usePageScrollLocationSelector(
11 | (y) => y > 100 && headerOpacity > 0.8,
12 | [headerOpacity],
13 | )
14 | return (
15 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/modules/dashboard/layouts/index.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, PropsWithChildren } from 'react'
2 |
3 | import { RootPortal } from '~/components/ui/portal'
4 | import { clsxm } from '~/lib/helper'
5 |
6 | export const MainLayout: FC = (props) => (
7 |
8 |
9 | {props.children}
10 |
11 |
12 | )
13 |
14 | export const OffsetMainLayout: Component = (props) => (
15 |
16 | {props.children}
17 |
18 | )
19 |
20 | export const OffsetHeaderLayout: Component = (props) => (
21 |
22 |
23 | {props.children}
24 |
25 |
26 | )
27 |
--------------------------------------------------------------------------------
/src/app/(app)/notes/layout.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import type { PropsWithChildren } from 'react'
3 |
4 | import { NoteLeftSidebar } from '~/components/modules/note/NoteLeftSidebar'
5 | import { LayoutRightSideProvider } from '~/providers/shared/LayoutRightSideProvider'
6 |
7 | export default async (props: PropsWithChildren) => {
8 | return (
9 |
17 |
18 |
19 |
20 |
21 | {props.children}
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/atoms/hooks/reader.ts:
--------------------------------------------------------------------------------
1 | import type { AuthUser } from '@mx-space/api-client'
2 | import { createAtomHooks } from 'jojoo/react'
3 | import { atom } from 'jotai'
4 |
5 | import { createAtomSelector } from '~/lib/atom'
6 | import type { SessionReader } from '~/models/session'
7 |
8 | export const [, , useSessionReader, , getSessionReader, setSessionReader] =
9 | createAtomHooks(atom(null))
10 |
11 | const [authReaderAtom, , , , getAuthReaders, _setAuthReaders] = createAtomHooks(
12 | atom>({}),
13 | )
14 | export { getAuthReaders }
15 |
16 | export const setAuthReaders = (readers: Record) => {
17 | _setAuthReaders({
18 | ...getAuthReaders(),
19 | ...readers,
20 | })
21 | }
22 | const useAuthReaderSelector = createAtomSelector(authReaderAtom)
23 | export const useAuthReader = (id: string): AuthUser | undefined => {
24 | return useAuthReaderSelector((readers) => readers[id], [id])
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/(app)/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 |
5 | // import { captureException } from '@sentry/nextjs'
6 | import { NormalContainer } from '~/components/layout/container/Normal'
7 | import { StyledButton } from '~/components/ui/button'
8 |
9 | export default ({ error, reset }: any) => {
10 | useEffect(() => {
11 | console.error('error', error)
12 | // captureException(error)
13 | }, [error])
14 |
15 | return (
16 |
17 |
18 |
19 |
渲染页面时出现了错误
20 |
21 | 多次出现错误请联系开发者 Innei
22 | ,谢谢!
23 |
24 |
25 |
location.reload()}>
26 | 刷新
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/atoms/hooks/owner.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@tanstack/react-query'
2 | import { useAtomValue } from 'jotai'
3 |
4 | import { getToken, setToken } from '~/lib/cookie'
5 | import { apiClient } from '~/lib/request'
6 | import { jotaiStore } from '~/lib/store'
7 |
8 | import { isLoggedAtom, ownerAtom } from '../owner'
9 | import { fetchAppUrl } from '../url'
10 |
11 | export const useIsLogged = () => useAtomValue(isLoggedAtom)
12 |
13 | export const useOwner = () => useAtomValue(ownerAtom)
14 | export const useRefreshToken = () => {
15 | return useMutation({
16 | mutationKey: ['refreshToken'],
17 | mutationFn: refreshToken,
18 | })
19 | }
20 |
21 | export const refreshToken = async () => {
22 | const token = getToken()
23 | if (!token) return
24 | await apiClient.user.proxy.login.put<{ token: string }>().then((res) => {
25 | jotaiStore.set(isLoggedAtom, true)
26 |
27 | setToken(res.token)
28 | })
29 |
30 | await fetchAppUrl()
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/icons/bookmark.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function RegularBookmark(props: SVGProps) {
4 | return (
5 |
11 | )
12 | }
13 |
14 | export function SolidBookmark(props: SVGProps) {
15 | return (
16 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ui/markdown/utils/image.ts:
--------------------------------------------------------------------------------
1 | export interface MImageType {
2 | name?: string
3 | url: string
4 | footnote?: string
5 | }
6 | export const pickImagesFromMarkdown = (md: string) => {
7 | const regexp =
8 | /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:[^\s\\]|\\.)*?)>?(?:\s+['"]([\s\S]*?)['"])?\s*\)/
9 |
10 | const lines = md.split('\n')
11 |
12 | const res: MImageType[] = []
13 |
14 | for (const line of lines) {
15 | if (!line.startsWith('!') && isRawImageUrl(line)) {
16 | res.push({ url: line, name: line })
17 | continue
18 | }
19 |
20 | const match = regexp.exec(line)
21 | if (!match) {
22 | continue
23 | }
24 |
25 | const [, name, url, footnote] = match
26 | res.push({ name, url, footnote })
27 | }
28 |
29 | return res
30 | }
31 |
32 | const isRawImageUrl = (url: string) => {
33 | try {
34 | new URL(url)
35 | } catch {
36 | return false
37 | }
38 | return true
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/helper.server.ts:
--------------------------------------------------------------------------------
1 | import { headers } from 'next/headers'
2 |
3 | import { isDev } from './env'
4 |
5 | export function escapeXml(unsafe: string) {
6 | return unsafe.replaceAll(/[<>&'"]/g, (c) => {
7 | switch (c) {
8 | case '<': {
9 | return '<'
10 | }
11 | case '>': {
12 | return '>'
13 | }
14 | case '&': {
15 | return '&'
16 | }
17 | case "'": {
18 | return '''
19 | }
20 | case '"': {
21 | return '"'
22 | }
23 | }
24 | return c
25 | })
26 | }
27 |
28 | export const getOgUrl = (type: 'post' | 'note' | 'page', data: any) => {
29 | const host = headers().get('host')
30 | const ogUrl = new URL(`${isDev ? 'http' : 'https'}://${host}/og`)
31 | ogUrl.searchParams.set(
32 | 'data',
33 | encodeURIComponent(
34 | JSON.stringify({
35 | type,
36 | ...data,
37 | }),
38 | ),
39 | )
40 | return ogUrl
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ui/loading/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { clsxm } from '~/lib/helper'
4 |
5 | export type LoadingProps = {
6 | loadingText?: string
7 | useDefaultLoadingText?: boolean
8 | }
9 |
10 | const defaultLoadingText = '别着急,坐和放宽'
11 | export const Loading: Component = ({
12 | loadingText,
13 | className,
14 | useDefaultLoadingText = false,
15 | }) => {
16 | const nextLoadingText = useDefaultLoadingText
17 | ? defaultLoadingText
18 | : loadingText
19 | return (
20 |
24 |
25 | {!!nextLoadingText && (
26 | {nextLoadingText}
27 | )}
28 |
29 | )
30 | }
31 |
32 | export const FullPageLoading = () => (
33 |
34 | )
35 |
--------------------------------------------------------------------------------
/src/hooks/biz/use-save-confirm.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | import { useModalStack } from '~/components/ui/modal/stacked/provider'
4 |
5 | /**
6 | *
7 | * @param enable
8 | * @param comparedFn true: 不提示,false: 提示
9 | */
10 | export const useSaveConfirm = (
11 | enable: boolean,
12 | comparedFn: () => boolean,
13 | message = '文章未保存是否确定离开?',
14 | ): void => {
15 | const beforeUnloadHandler = (event: any) => {
16 | if (comparedFn()) {
17 | return
18 | }
19 | event.preventDefault()
20 |
21 | // Chrome requires returnValue to be set.
22 | event.returnValue = message
23 | return false
24 | }
25 |
26 | useEffect(() => {
27 | if (enable) {
28 | window.addEventListener('beforeunload', beforeUnloadHandler)
29 | }
30 |
31 | return () => {
32 | if (enable) {
33 | window.removeEventListener('beforeunload', beforeUnloadHandler)
34 | }
35 | }
36 | }, [])
37 |
38 | const { present } = useModalStack()
39 | }
40 |
--------------------------------------------------------------------------------
/src/providers/root/auth-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useQuery } from '@tanstack/react-query'
4 | import { useRouter } from 'next/navigation'
5 | import type { FC, PropsWithChildren } from 'react'
6 |
7 | import { PageLoading } from '~/components/layout/dashboard/PageLoading'
8 | import { apiClient } from '~/lib/request'
9 | import { toast } from '~/lib/toast'
10 |
11 | export const AuthProvider: FC = ({ children }) => {
12 | const { data: ok, isLoading } = useQuery({
13 | queryKey: ['check-auth'],
14 | // 5 min ,
15 | refetchInterval: 5 * 60 * 1000,
16 | queryFn: async () => {
17 | const { ok } = await apiClient.proxy('master')('check_logged').get<{
18 | ok: number
19 | }>()
20 |
21 | return !!ok
22 | },
23 | })
24 | const router = useRouter()
25 | if (isLoading) return
26 |
27 | if (!ok) {
28 | toast.error('Not auth!')
29 | router.push('/')
30 | }
31 |
32 | return children
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/icons/platform/FacebookIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from 'react'
2 |
3 | export function LogosFacebook(props: SVGProps) {
4 | return (
5 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------