@@ -1,14 +1,23 @@
'use client' ;
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query' ;
import Link from 'next/link' ;
import { useRouter } from 'next/navigation' ;
import { useState , type FormEvent } from 'react' ;
import { EmptyState } from '@/components/feedback/empty-state' ;
import { ErrorState } from '@/components/feedback/error-state' ;
import { LoadingState } from '@/components/feedback/loading-state' ;
import { PageHeader } from '@/components/layout/page-header' ;
import { AppCard } from '@/components/ui/app-card' ;
import { AppModal } from '@/components/ui/app-modal' ;
import { StatusBadge } from '@/components/ui/status-badge' ;
import {
activatePagesDeployment ,
createPagesProject ,
deletePagesDeployment ,
deletePagesProject ,
getPagesProject ,
getPagesDeployments ,
getPagesProjects ,
uploadPagesDeployment ,
@@ -25,6 +34,14 @@ import {
const projectsQueryKey = [ 'pages-projects' ] ;
function projectQueryKey ( projectId : string | number ) {
return [ 'pages-project' , String ( projectId ) ] ;
}
function deploymentsQueryKey ( projectId : string | number ) {
return [ 'pages-deployments' , Number ( projectId ) ] ;
}
function formatBytes ( value : number ) {
if ( value < 1024 ) {
return ` ${ value } B ` ;
@@ -43,104 +60,38 @@ function formatDate(value?: string | null) {
}
export function PagesPage() {
const queryClient = useQueryClient ( ) ;
const [ name , setName ] = useState ( '' ) ;
const [ slug , setSlug ] = useState ( '' ) ;
const [ description , setDescription ] = useState ( '' ) ;
const [ spaFallbackEnabled , setSpaFallbackEnabled ] = useState ( false ) ;
const [ isCreateModalOpen , setCreateModalOpen ] = useState ( false ) ;
const projectsQuery = useQuery ( {
queryKey : projectsQueryKey ,
queryFn : getPagesProjects ,
} ) ;
const createMutation = useMutation ( {
mutationFn : ( ) = >
createPagesProject ( {
name ,
slug ,
description ,
enabled : true ,
spa_fallback_enabled : spaFallbackEnabled ,
} ) ,
onSuccess : ( ) = > {
setName ( '' ) ;
setSlug ( '' ) ;
setDescription ( '' ) ;
setSpaFallbackEnabled ( false ) ;
queryClient . invalidateQueries ( { queryKey : projectsQueryKey } ) ;
} ,
} ) ;
function handleCreate ( event : FormEvent < HTMLFormElement > ) {
event . preventDefault ( ) ;
createMutation . mutate ( ) ;
}
return (
< div className = "space-y-6" >
< div className = "space-y-2 " >
< p className = "text-sm font-medium text-[var(--foreground-secondary)] " >
OpenFlare Pages
< / p >
< h1 className = "text-2xl font-semibold text-[var(--foreground-primary)]" >
边 缘 静 态 站 点 托 管
< / h1 >
< p className = "max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]" >
创 建 Pages 项 目 , 上 传 已 构 建 的 zip 静 态 资 源 包 , 然 后 在 规 则 中 选 择 Pages
项 目 作 为 上 游 。 发 布 后 Agent 会 拉 取 部 署 包 并 在 边 缘 节 点 本 地 服 务 静 态 文 件 。
< / p >
< div className = "flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between " >
< div className = "space-y-2 " >
< p className = "text-sm font-medium text-[var(--foreground-secondary)]" >
OpenFlare Pages
< / p >
< h1 className = "text-2xl font-semibold text-[var(--foreground-primary)]" >
边 缘 静 态 站 点 托 管
< / h1 >
< p className = "max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]" >
创 建 Pages 项 目 , 上 传 已 构 建 的 zip 静 态 资 源 包 , 然 后 在 规 则 中 选 择 Pages
项 目 作 为 上 游 。 发 布 后 Agent
会 拉 取 部 署 包 并 在 边 缘 节 点 本 地 服 务 静 态 文 件 。
< / p >
< / div >
< PrimaryButton
type = "button"
className = "w-full lg:w-auto"
onClick = { ( ) = > setCreateModalOpen ( true ) }
>
新 建 Pages 项 目
< / PrimaryButton >
< / div >
< AppCard
title = "新建 Pages 项目"
description = "V1 仅支持 Direct Upload,不执行 Git 构建或边缘函数运行时。"
>
< form className = "grid gap-4 md:grid-cols-2" onSubmit = { handleCreate } >
< ResourceField label = "项目名称" >
< ResourceInput
value = { name }
placeholder = "Marketing Site"
onChange = { ( event ) = > setName ( event . target . value ) }
required
/ >
< / ResourceField >
< ResourceField label = "项目标识" hint = "留空时会按名称自动生成。" >
< ResourceInput
value = { slug }
placeholder = "marketing-site"
onChange = { ( event ) = > setSlug ( event . target . value ) }
/ >
< / ResourceField >
< ResourceField label = "描述" className = "md:col-span-2" >
< ResourceInput
value = { description }
placeholder = "这个项目托管的静态站点用途"
onChange = { ( event ) = > setDescription ( event . target . value ) }
/ >
< / ResourceField >
< ToggleField
label = "启用 SPA fallback"
description = "开启后未命中的路径会回退到 /index.html,适合 React/Vue history 路由。"
checked = { spaFallbackEnabled }
onChange = { setSpaFallbackEnabled }
/ >
< div className = "flex items-end justify-end" >
< PrimaryButton
type = "submit"
disabled = { createMutation . isPending || name . trim ( ) === '' }
>
{ createMutation . isPending ? '创建中...' : '创建项目' }
< / PrimaryButton >
< / div >
{ createMutation . error ? (
< p className = "text-sm text-[var(--status-danger-foreground)] md:col-span-2" >
{ createMutation . error . message }
< / p >
) : null }
< / form >
< / AppCard >
< div className = "space-y-4" >
{ projectsQuery . isLoading ? (
< AppCard > 正 在 加 载 Pages 项 目 . . . < / AppCard >
@@ -151,28 +102,55 @@ export function PagesPage() {
< / p >
< / AppCard >
) : ( projectsQuery . data ? ? [ ] ) . length === 0 ? (
< AppCard >
< p className = "text-sm text-[var(--foreground-secondary)]" >
还 没 有 Pages 项 目 。 先 创 建 一 个 项 目 , 再 上 传 静 态 资 源 包 。
< AppCard
title = "还没有 Pages 项目"
description = "先创建一个项目,再上传静态资源包。"
action = {
< SecondaryButton
type = "button"
onClick = { ( ) = > setCreateModalOpen ( true ) }
>
新 建 Pages 项 目
< / SecondaryButton >
}
>
< p className = "text-sm leading-6 text-[var(--foreground-secondary)]" >
V1 仅 支 持 Direct Upload , 不 执 行 Git
构 建 或 边 缘 函 数 运 行 时 。 创 建 后 可 在 项 目 卡 片 中 上 传 zip
部 署 包 并 激 活 版 本 。
< / p >
< / AppCard >
) : (
( projectsQuery . data ? ? [ ] ) . map ( ( project ) = > (
< PagesProjectCard key = { project . id } project = { project } / >
< PagesProjectListItem key = { project . id } project = { project } / >
) )
) }
< / div >
< PagesProjectCreateModal
isOpen = { isCreateModalOpen }
onClose = { ( ) = > setCreateModalOpen ( false ) }
/ >
< / div >
) ;
}
function PagesProjectCard ( { project } : { project : PagesProject } ) {
export function PagesProjectDetailPage ( { projectId } : { projectId : string } ) {
const router = useRouter ( ) ;
const queryClient = useQueryClient ( ) ;
const [ file , setFile ] = useState < File | null > ( null ) ;
const [ entryFile , setEntryFile ] = useState ( 'index.html' ) ;
const parsedProjectId = Number ( projectId ) ;
const projectQuery = useQuery ( {
queryKey : projectQueryKey ( projectId ) ,
queryFn : ( ) = > getPagesProject ( parsedProjectId ) ,
enabled : projectId !== '' && Number . isFinite ( parsedProjectId ) ,
} ) ;
const deploymentsQuery = useQuery ( {
queryKey : [ 'pages-deployments' , project . id ] ,
queryFn : ( ) = > getPagesDeployments ( project . id ) ,
queryKey : deploymentsQueryKey ( parsedProjectId ) ,
queryFn : ( ) = > getPagesDeployments ( parsedProjectId ) ,
enabled : projectId !== '' && Number . isFinite ( parsedProjectId ) ,
} ) ;
const uploadMutation = useMutation ( {
@@ -180,83 +158,180 @@ function PagesProjectCard({ project }: { project: PagesProject }) {
if ( ! file ) {
throw new Error ( '请选择 zip 文件' ) ;
}
return uploadPagesDeployment ( project . id , file , entryFile ) ;
return uploadPagesDeployment ( parsedProjectId , file , entryFile ) ;
} ,
onSuccess : ( ) = > {
setFile ( null ) ;
queryClient . invalidateQueries ( {
queryKey : [ 'pages-deployments' , project . id ] ,
queryKey : deploymentsQueryKey ( parsedProjectId ) ,
} ) ;
queryClient . invalidateQueries ( { queryKey : projectQueryKey ( projectId ) } ) ;
queryClient . invalidateQueries ( { queryKey : projectsQueryKey } ) ;
} ,
} ) ;
const activateMutation = useMutation ( {
mutationFn : ( deploymentId : number ) = >
activatePagesDeployment ( project . id , deploymentId ) ,
activatePagesDeployment ( parsedProjectId , deploymentId ) ,
onSuccess : ( ) = > {
queryClient . invalidateQueries ( {
queryKey : [ 'pages-deployments' , project . id ] ,
queryKey : deploymentsQueryKey ( parsedProjectId ) ,
} ) ;
queryClient . invalidateQueries ( { queryKey : projectQueryKey ( projectId ) } ) ;
queryClient . invalidateQueries ( { queryKey : projectsQueryKey } ) ;
} ,
} ) ;
const deleteDeploymentMutation = useMutation ( {
mutationFn : ( deploymentId : number ) = >
deletePagesDeployment ( project . id , deploymentId ) ,
deletePagesDeployment ( parsedProjectId , deploymentId ) ,
onSuccess : ( ) = > {
queryClient . invalidateQueries ( {
queryKey : [ 'pages-deployments' , project . id ] ,
queryKey : deploymentsQueryKey ( parsedProjectId ) ,
} ) ;
queryClient . invalidateQueries ( { queryKey : projectQueryKey ( projectId ) } ) ;
queryClient . invalidateQueries ( { queryKey : projectsQueryKey } ) ;
} ,
} ) ;
const deleteProjectMutation = useMutation ( {
mutationFn : ( ) = > deletePagesProject ( project . id ) ,
mutationFn : ( ) = > deletePagesProject ( parsedProjectId ) ,
onSuccess : ( ) = > {
queryClient . invalidateQueries ( { queryKey : projectsQueryKey } ) ;
router . push ( '/pages' ) ;
} ,
} ) ;
if ( projectId === '' || ! Number . isFinite ( parsedProjectId ) ) {
return (
< EmptyState
title = "Pages 项目不存在"
description = "缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
/ >
) ;
}
if ( projectQuery . isLoading ) {
return < LoadingState / > ;
}
if ( projectQuery . isError ) {
return (
< ErrorState
title = "Pages 项目加载失败"
description = { projectQuery . error . message }
/ >
) ;
}
const project = projectQuery . data ;
if ( ! project ) {
return (
< EmptyState
title = "Pages 项目不存在"
description = "该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
/ >
) ;
}
const handleDeleteProject = ( ) = > {
if ( ! window . confirm ( ` 确认删除 Pages 项目 ${ project . name } 吗? ` ) ) {
return ;
}
deleteProjectMutation . mutate ( ) ;
} ;
return (
< AppCard
title = { project . name }
description = { ` ${ project . slug } · ${
project . spa_fallback_enabled ? 'SPA fallback 已启用' : '严格 404'
} ` }
action = {
< DangerButton
type = "button "
disabled = { deleteProjectMutation . isPending }
onClick = { ( ) = > {
if ( window . confirm ( ` 确认删除 Pages 项目 ${ project . name } 吗? ` ) ) {
deleteProjectMutation . mutate ( ) ;
}
} }
>
删 除 项 目
< / DangerButton >
}
>
< div className = "grid gap-6 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" >
< div className = "space-y-4" >
< div className = "rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4" >
< p className = "text-xs text-[var(--foreground-secondary)]" >
当 前 激 活 部 署
< / p >
< p className = "mt-2 text-sm font-medium text-[var(--foreground-primary)]" >
{ project . active_deployment
< div className = "space-y-6" >
< PageHeader
title = { project . name }
description = { ` ${ project . slug } · Pages 静态站点项目详情 ` }
action = {
< >
< Link
href = "/pages "
className = "inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返 回 列 表
< / Link >
< DangerButton
type = "button"
disabled = { deleteProjectMutation . isPending }
onClick = { handleDeleteProject }
>
删 除 项 目
< / DangerButton >
< / >
}
/ >
< AppCard
title = "项目概览"
description = "Pages 项目的发布状态、静态回退策略和规则可选性集中在这里。"
>
< div className = "grid gap-3 md:grid-cols-2 xl:grid-cols-4" >
< OverviewItem
label = "当前激活部署"
value = {
project . active_deployment
? ` # ${ project . active_deployment . deployment_number } · ${ project . active_deployment . checksum . slice ( 0 , 12 ) } `
: '暂无激活部署' }
: '暂无激活部署'
}
hint = { ` 激活时间: ${ formatDate ( project . active_deployment ? . activated_at ) } ` }
/ >
< OverviewItem
label = "部署数量"
value = { ` ${ project . deployment_count } ` }
hint = "上传后需要手动激活部署"
/ >
< OverviewItem
label = "SPA fallback"
value = {
project . spa_fallback_enabled
? project . spa_fallback_path || '/index.html'
: '严格 404'
}
hint = {
project . spa_fallback_enabled
? '未命中路径会回退到该文件'
: '未命中路径直接返回 404'
}
/ >
< div className = "rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3" >
< p className = "text-xs text-[var(--foreground-secondary)]" >
项 目 状 态
< / p >
< p className = "mt-1 text-xs text-[var(--foreground-secondary)] " >
激 活 时 间 : { formatDate ( project . active_deployment ? . activated_at ) }
< div className = "mt-2 flex flex-wrap gap-2 " >
< StatusBadge
label = { project . enabled ? '已启用' : '已停用' }
variant = { project . enabled ? 'success' : 'warning' }
/ >
< StatusBadge
label = {
project . enabled && project . active_deployment
? '规则可选'
: '规则不可选'
}
variant = {
project . enabled && project . active_deployment
? 'success'
: 'warning'
}
/ >
< / div >
< p className = "mt-2 text-xs text-[var(--foreground-secondary)]" >
需 要 启 用 且 有 激 活 部 署
< / p >
< / div >
< / div >
< / AppCard >
< div className = "space-y-3 " >
< div className = "grid gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] " >
< AppCard
title = "上传部署包"
description = "上传已构建的 zip 静态资源包,默认入口 index.html。"
>
< div className = "space-y-4" >
< ResourceField
label = "上传 部署包"
hint = "仅支持 zip, 默认入口 index.html 。"
label = "部署包"
hint = "仅支持 zip, Server 会校验文件数量、体积、路径逃逸和入口文件 。"
>
< ResourceInput
type = "file"
@@ -283,20 +358,25 @@ function PagesProjectCard({ project }: { project: PagesProject }) {
< / p >
) : null }
< / div >
< / div >
< / AppCard >
< div className = "space-y-3" >
< h3 className = "text-sm font-medium text-[var(--foreground-primary)]" >
部 署 历 史
< / h3 >
< AppCard
title = "部署历史"
description = "部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
>
{ deploymentsQuery . isLoading ? (
< p className = "text-sm text-[var(--foreground-secondary)]" >
加 载 中 . . .
< / p >
) : ( deploymentsQuery . data ? ? [ ] ) . length === 0 ? (
< p className = "text-sm text-[var(--foreground-secondary )]" >
暂 无 部 署 。 上 传 zip 后 再 激 活 部 署 。
) : deploymentsQuery . isError ? (
< p className = "text-sm text-[var(--status-danger- foreground)]" >
{ deploymentsQuery . error . message }
< / p >
) : ( deploymentsQuery . data ? ? [ ] ) . length === 0 ? (
< EmptyState
title = "暂无部署"
description = "上传 zip 部署包后,可以在这里激活某个部署版本。"
/ >
) : (
< div className = "overflow-hidden rounded-2xl border border-[var(--border-default)]" >
{ ( deploymentsQuery . data ? ? [ ] ) . map ( ( deployment ) = > (
@@ -357,8 +437,223 @@ function PagesProjectCard({ project }: { project: PagesProject }) {
) ) }
< / div >
) }
< / div >
< / AppCard >
< / div >
< / AppCard >
< / div >
) ;
}
function PagesProjectCreateModal ( {
isOpen ,
onClose ,
} : {
isOpen : boolean ;
onClose : ( ) = > void ;
} ) {
const queryClient = useQueryClient ( ) ;
const [ name , setName ] = useState ( '' ) ;
const [ slug , setSlug ] = useState ( '' ) ;
const [ description , setDescription ] = useState ( '' ) ;
const [ spaFallbackEnabled , setSpaFallbackEnabled ] = useState ( false ) ;
const [ spaFallbackPath , setSpaFallbackPath ] = useState ( '/index.html' ) ;
const resetForm = ( ) = > {
setName ( '' ) ;
setSlug ( '' ) ;
setDescription ( '' ) ;
setSpaFallbackEnabled ( false ) ;
setSpaFallbackPath ( '/index.html' ) ;
} ;
const closeModal = ( ) = > {
resetForm ( ) ;
onClose ( ) ;
} ;
const createMutation = useMutation ( {
mutationFn : ( ) = >
createPagesProject ( {
name ,
slug ,
description ,
enabled : true ,
spa_fallback_enabled : spaFallbackEnabled ,
spa_fallback_path : spaFallbackPath ,
} ) ,
onSuccess : ( ) = > {
resetForm ( ) ;
onClose ( ) ;
queryClient . invalidateQueries ( { queryKey : projectsQueryKey } ) ;
} ,
} ) ;
function handleCreate ( event : FormEvent < HTMLFormElement > ) {
event . preventDefault ( ) ;
createMutation . mutate ( ) ;
}
return (
< AppModal
isOpen = { isOpen }
onClose = { closeModal }
title = "新建 Pages 项目"
description = "配置静态站点项目的基础信息。创建后再上传已构建的 zip 部署包。"
footer = {
< div className = "flex flex-wrap justify-end gap-3" >
< SecondaryButton type = "button" onClick = { closeModal } >
取 消
< / SecondaryButton >
< PrimaryButton
type = "submit"
form = "pages-project-create-form"
disabled = { createMutation . isPending || name . trim ( ) === '' }
>
{ createMutation . isPending ? '创建中...' : '创建项目' }
< / PrimaryButton >
< / div >
}
>
< form
id = "pages-project-create-form"
className = "grid gap-4 md:grid-cols-2"
onSubmit = { handleCreate }
>
< ResourceField label = "项目名称" >
< ResourceInput
value = { name }
placeholder = "Marketing Site"
onChange = { ( event ) = > setName ( event . target . value ) }
required
/ >
< / ResourceField >
< ResourceField label = "项目标识" hint = "留空时会按名称自动生成。" >
< ResourceInput
value = { slug }
placeholder = "marketing-site"
onChange = { ( event ) = > setSlug ( event . target . value ) }
/ >
< / ResourceField >
< ResourceField label = "描述" className = "md:col-span-2" >
< ResourceInput
value = { description }
placeholder = "这个项目托管的静态站点用途"
onChange = { ( event ) = > setDescription ( event . target . value ) }
/ >
< / ResourceField >
< ToggleField
label = "启用 SPA fallback"
description = "开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
checked = { spaFallbackEnabled }
onChange = { setSpaFallbackEnabled }
/ >
< ResourceField
label = "SPA 回退路径"
hint = "以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
>
< ResourceInput
value = { spaFallbackPath }
placeholder = "/index.html"
disabled = { ! spaFallbackEnabled }
onChange = { ( event ) = > setSpaFallbackPath ( event . target . value ) }
/ >
< / ResourceField >
< div className = "rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3 text-xs leading-5 text-[var(--foreground-secondary)]" >
V1 仅 保 存 项 目 和 部 署 包 , 不 执 行 构 建 任 务 ; 请 先 在 本 地 或 CI
中 完 成 静 态 资 源 构 建 。
< / div >
{ createMutation . error ? (
< p className = "text-sm text-[var(--status-danger-foreground)] md:col-span-2" >
{ createMutation . error . message }
< / p >
) : null }
< / form >
< / AppModal >
) ;
}
function PagesProjectListItem ( { project } : { project : PagesProject } ) {
return (
< Link
href = { ` /pages/detail?id= ${ project . id } ` }
className = "group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
>
< div className = "flex flex-col gap-4 md:flex-row md:items-center md:justify-between" >
< div className = "min-w-0 space-y-2" >
< div className = "flex flex-wrap items-center gap-2" >
< h2 className = "text-lg font-semibold text-[var(--foreground-primary)]" >
{ project . name }
< / h2 >
< StatusBadge
label = { project . enabled ? '已启用' : '已停用' }
variant = { project . enabled ? 'success' : 'warning' }
/ >
< StatusBadge
label = { project . spa_fallback_enabled ? 'SPA fallback' : '严格 404' }
variant = { project . spa_fallback_enabled ? 'info' : 'warning' }
/ >
< / div >
< p className = "text-sm text-[var(--foreground-secondary)]" >
{ project . slug }
< / p >
{ project . description ? (
< p className = "line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]" >
{ project . description }
< / p >
) : null }
{ project . spa_fallback_enabled ? (
< p className = "text-xs text-[var(--foreground-secondary)]" >
回 退 路 径 : { project . spa_fallback_path || '/index.html' }
< / p >
) : null }
< / div >
< div className = "grid shrink-0 grid-cols-2 gap-3 text-sm md:min-w-80" >
< div className = "rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3" >
< p className = "text-xs text-[var(--foreground-secondary)]" > 部 署 数 < / p >
< p className = "mt-1 font-semibold text-[var(--foreground-primary)]" >
{ project . deployment_count }
< / p >
< / div >
< div className = "rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3" >
< p className = "text-xs text-[var(--foreground-secondary)]" >
当 前 激 活
< / p >
< p className = "mt-1 font-semibold text-[var(--foreground-primary)]" >
{ project . active_deployment
? ` # ${ project . active_deployment . deployment_number } `
: '暂无' }
< / p >
< / div >
< / div >
< / div >
< div className = "mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4" >
< p className = "text-xs text-[var(--foreground-secondary)]" >
激 活 时 间 : { formatDate ( project . active_deployment ? . activated_at ) }
< / p >
< span className = "text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1" >
查 看 详 情 →
< / span >
< / div >
< / Link >
) ;
}
function OverviewItem ( {
label ,
value ,
hint ,
} : {
label : string ;
value : string ;
hint : string ;
} ) {
return (
< div className = "rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3" >
< p className = "text-xs text-[var(--foreground-secondary)]" > { label } < / p >
< p className = "mt-2 truncate text-sm font-semibold text-[var(--foreground-primary)]" >
{ value }
< / p >
< p className = "mt-2 text-xs text-[var(--foreground-secondary)]" > { hint } < / p >
< / div >
) ;
}