diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f77d49771b..04657ae231 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -26,7 +26,7 @@ export const generateMetadata = defineMetadata(async (_, getData) => {
return {
metadataBase: new URL(url.webUrl),
title: {
- template: `%s | ${seo.title}`,
+ template: `%s - ${seo.title}`,
default: `${seo.title} - ${seo.description}`,
},
description: seo.description,
diff --git a/src/app/projects/[id]/layout.tsx b/src/app/projects/[id]/layout.tsx
new file mode 100644
index 0000000000..f4e8b80f63
--- /dev/null
+++ b/src/app/projects/[id]/layout.tsx
@@ -0,0 +1,8 @@
+import type { PropsWithChildren } from 'react'
+
+export const metadata = {
+ title: '项目详情',
+}
+export default function Page(props: PropsWithChildren) {
+ return props.children
+}
diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx
new file mode 100644
index 0000000000..763449f62b
--- /dev/null
+++ b/src/app/projects/[id]/page.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+import { useEffect } from 'react'
+import { useParams, useRouter } from 'next/navigation'
+
+import { NotFound404 } from '~/components/common/404'
+import { Loading } from '~/components/ui/loading'
+import { apiClient } from '~/utils/request'
+
+export default function Page() {
+ const { id } = useParams()
+
+ const { data, isLoading } = useQuery({
+ queryKey: [id, 'project'],
+ queryFn: async ({ queryKey }) => {
+ const [id] = queryKey
+ return apiClient.project.getById(id)
+ },
+ })
+ const router = useRouter()
+ useEffect(() => {
+ if (data?.projectUrl) {
+ window.open(data.projectUrl)
+ router.back()
+ }
+ }, [data?.projectUrl])
+
+ if (isLoading) {
+ return
+ }
+
+ if (!data) {
+ return
+ }
+
+ return null
+}
diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx
new file mode 100644
index 0000000000..dc4288f695
--- /dev/null
+++ b/src/app/projects/layout.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next'
+import type { PropsWithChildren } from 'react'
+
+import { WiderContainer } from '~/components/layout/container/Wider'
+
+export const metadata: Metadata = {
+ title: '项目',
+}
+export default async function Layout(props: PropsWithChildren) {
+ return {props.children}
+}
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
new file mode 100644
index 0000000000..afbea7a730
--- /dev/null
+++ b/src/app/projects/page.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+
+import { CodiconGithubInverted } from '~/components/icons/menu-collection'
+import { Loading } from '~/components/ui/loading'
+import { BottomToUpTransitionView } from '~/components/ui/transition/BottomToUpTransitionView'
+import { ProjectList } from '~/components/widgets/project/ProjectList'
+import { NothingFound } from '~/components/widgets/shared/NothingFound'
+import { noopArr } from '~/lib/noop'
+import { useAggregationSelector } from '~/providers/root/aggregation-data-provider'
+import { apiClient } from '~/utils/request'
+
+export default function Page() {
+ const { data, isLoading } = useQuery({
+ queryKey: ['projects'],
+ queryFn: async () => {
+ const data = await apiClient.project.getAll()
+ return data.data
+ },
+ })
+
+ const githubUsername = useAggregationSelector(
+ (state) => state.user?.socialIds?.github,
+ )
+
+ if (isLoading) {
+ return
+ }
+
+ if (!data) return
+
+ return (
+
+
+
+
+
+ 项目{' '}
+ {githubUsername && (
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout/header/internal/HeaderContent.tsx b/src/components/layout/header/internal/HeaderContent.tsx
index 05f76f9584..ff67801cf6 100644
--- a/src/components/layout/header/internal/HeaderContent.tsx
+++ b/src/components/layout/header/internal/HeaderContent.tsx
@@ -2,7 +2,12 @@
import React, { memo } from 'react'
import clsx from 'clsx'
-import { AnimatePresence, m, useMotionValue } from 'framer-motion'
+import {
+ AnimatePresence,
+ m,
+ useMotionTemplate,
+ useMotionValue,
+} from 'framer-motion'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type { IHeaderMenu } from '../config'
@@ -12,7 +17,11 @@ import { usePageScrollDirectionSelector } from '~/providers/root/page-scroll-inf
import { clsxm } from '~/utils/helper'
import { useHeaderConfig } from './HeaderDataConfigureProvider'
-import { useHeaderBgOpacity, useMenuOpacity } from './hooks'
+import {
+ useHeaderBgOpacity,
+ useHeaderHasMetaInfo,
+ useMenuOpacity,
+} from './hooks'
import { MenuPopover } from './MenuPopover'
export const HeaderContent = () => {
@@ -28,12 +37,13 @@ export const HeaderContent = () => {
const AccessibleMenu: Component = () => {
const headerOpacity = useHeaderBgOpacity()
+ const hasMetaInfo = useHeaderHasMetaInfo()
const showShow = usePageScrollDirectionSelector(
(d) => {
- return d === 'up' && headerOpacity > 0.8
+ return d === 'up' && headerOpacity > 0.8 && hasMetaInfo
},
- [headerOpacity],
+ [headerOpacity, hasMetaInfo],
)
return (
@@ -56,20 +66,25 @@ const AccessibleMenu: Component = () => {
const AnimatedMenu: Component = ({ children }) => {
const opacity = useMenuOpacity()
+ const hasMetaInfo = useHeaderHasMetaInfo()
+ const shouldHideNavBg = !hasMetaInfo && opacity === 0
return (
- {children}
+ {/* @ts-ignore */}
+ {React.cloneElement(children, { shouldHideNavBg })}
)
}
-const ForDesktop: Component = ({ className }) => {
+const ForDesktop: Component<{
+ shouldHideNavBg?: boolean
+}> = ({ className, shouldHideNavBg }) => {
const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)
const radius = useMotionValue(0)
@@ -86,6 +101,8 @@ const ForDesktop: Component = ({ className }) => {
const { config: headerMenuConfig } = useHeaderConfig()
const pathname = usePathname()
+ const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, var(--spotlight-color) 0%, transparent 65%)`
+
return (
{
'rounded-full bg-gradient-to-b from-zinc-50/70 to-white/90',
'shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur-md',
'dark:from-zinc-900/70 dark:to-zinc-800/90 dark:ring-zinc-100/10',
-
+ 'group [--spotlight-color:hsl(var(--a)_/_0.05)]',
+ 'duration-200',
+ shouldHideNavBg && 'bg-none shadow-none ring-transparent',
className,
)}
>
+ {/* Spotlight overlay */}
+
{headerMenuConfig.map((section) => {
+ const subItemActive =
+ section.subMenu?.findIndex((item) => item.path === pathname) || -1
return (
-1 ||
+ false
}
/>
)
@@ -120,7 +150,8 @@ const ForDesktop: Component = ({ className }) => {
const HeaderMenuItem = memo<{
section: IHeaderMenu
isActive: boolean
-}>(({ section, isActive }) => {
+ subItemActive?: IHeaderMenu
+}>(({ section, isActive, subItemActive }) => {
const href = section.path
return (
@@ -136,10 +167,10 @@ const HeaderMenuItem = memo<{
layoutId="header-menu-icon"
className={clsxm('mr-2 flex items-center')}
>
- {section.icon}
+ {subItemActive?.icon ?? section.icon}
)}
- {section.title}
+ {subItemActive?.title ?? section.title}
diff --git a/src/components/layout/header/internal/hooks.ts b/src/components/layout/header/internal/hooks.ts
index 646540a5a2..5a38cf67aa 100644
--- a/src/components/layout/header/internal/hooks.ts
+++ b/src/components/layout/header/internal/hooks.ts
@@ -77,3 +77,13 @@ export const useHeaderMetaInfo = () => {
slug: useAtomValue(headerMetaSlugAtom),
}
}
+
+const headerHasMetaInfoAtom = atom((get) => {
+ const title = get(headerMetaTitleAtom)
+ const description = get(headerMetaDescriptionAtom)
+
+ return title !== '' && description !== ''
+})
+export const useHeaderHasMetaInfo = () => {
+ return useAtomValue(headerHasMetaInfoAtom)
+}
diff --git a/src/components/ui/text/FlexText.tsx b/src/components/ui/text/FlexText.tsx
index 222a88edd3..d8440fa474 100644
--- a/src/components/ui/text/FlexText.tsx
+++ b/src/components/ui/text/FlexText.tsx
@@ -35,3 +35,5 @@ export const FlexText: FC<{ text: string; scale: number }> = memo((props) => {
)
})
+
+FlexText.displayName = 'FlexText'
diff --git a/src/components/widgets/project/ProjectIcon.tsx b/src/components/widgets/project/ProjectIcon.tsx
new file mode 100644
index 0000000000..da145550cc
--- /dev/null
+++ b/src/components/widgets/project/ProjectIcon.tsx
@@ -0,0 +1,30 @@
+import { ImageLazy } from '~/components/ui/image'
+import { FlexText } from '~/components/ui/text'
+import { clsxm } from '~/utils/helper'
+
+export const ProjectIcon: Component<{ avatar?: string; name?: string }> = (
+ props,
+) => {
+ const { avatar, name, className } = props
+ return (
+
+ {avatar ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/components/widgets/project/ProjectList.tsx b/src/components/widgets/project/ProjectList.tsx
new file mode 100644
index 0000000000..00515702a3
--- /dev/null
+++ b/src/components/widgets/project/ProjectList.tsx
@@ -0,0 +1,44 @@
+import Link from 'next/link'
+import type { FC } from 'react'
+
+import { routeBuilder, Routes } from '~/lib/route-builder'
+
+import { ProjectIcon } from './ProjectIcon'
+
+export type Project = {
+ id: string
+ avatar?: string
+ name: string
+ description?: string
+}
+export const ProjectList: FC<{ projects: Project[] }> = (props) => {
+ const projects = props.projects
+
+ return (
+
+
+ {projects.map((project) => {
+ return (
+
+
+
+ {project.name}
+
+ {project.description}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/lib/route-builder.ts b/src/lib/route-builder.ts
index 33cd5bda5e..a62814c19d 100644
--- a/src/lib/route-builder.ts
+++ b/src/lib/route-builder.ts
@@ -17,6 +17,9 @@ export enum Routes {
Categories = '/categories',
Category = '/categories/',
+
+ Projects = '/projects',
+ Project = '/projects/',
}
type Noop = never
@@ -46,6 +49,10 @@ type TimelineParams = {
type OnlySlug = {
slug: string
}
+
+type OnlyId = {
+ id: string
+}
export type RouteParams = T extends Routes.Home
? HomeParams
: T extends Routes.Note
@@ -66,6 +73,8 @@ export type RouteParams = T extends Routes.Home
? OnlySlug
: T extends Routes.Category
? OnlySlug
+ : T extends Routes.Project
+ ? OnlyId
: never
export const routeBuilder = (
@@ -109,6 +118,11 @@ export const routeBuilder = (
href = '/'
break
}
+ case Routes.Project: {
+ const p = params as OnlyId
+ href += p.id
+ break
+ }
case Routes.NoteTopics:
case Routes.Notes:
case Routes.Login: {