feat(dashboard): implement dashboard overview component and related services

- Added DashboardModule, DashboardController, and DashboardService to manage dashboard functionality.
- Created DashboardOverview component to display statistics and recent activity.
- Introduced API hooks for fetching dashboard data and handling state.
- Updated AGENTS.md to reflect new page structure for modular organization.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-10-31 20:18:36 +08:00
parent 22ea1f97aa
commit 552947c2cd
14 changed files with 593 additions and 73 deletions

View File

@@ -163,6 +163,7 @@ class PhotoLoader {
- **Hot Reloading**: SPA changes reflect immediately, SSR provides SEO preview
- **Manifest Building**: `pnpm run build:manifest` processes photos and updates data
- **Type Safety**: Shared types between builder, SPA, and SSR ensure consistency
- **Page Structure**: Keep files under `pages/` as thin routing shells; move reusable UI/logic into `modules/<domain>/**` (e.g., dashboard overview lives in `modules/dashboard/components`).
### Code Quality Rules

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import { DashboardService } from './dashboard.service'
@Controller('dashboard')
@Roles('admin')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('overview')
async getOverview() {
return await this.dashboardService.getOverview()
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from '../../database/database.module'
import { DashboardController } from './dashboard.controller'
import { DashboardService } from './dashboard.service'
@Module({
imports: [DatabaseModule],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,96 @@
import { photoAssets } from '@afilmory/db'
import { desc, eq, sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { requireTenantContext } from '../tenant/tenant.context'
import type { DashboardOverview, DashboardRecentActivityItem } from './dashboard.types'
const ZERO_STATS = {
totalPhotos: 0,
totalStorageBytes: 0,
thisMonthUploads: 0,
previousMonthUploads: 0,
sync: {
synced: 0,
pending: 0,
conflicts: 0,
},
} as const
@injectable()
export class DashboardService {
constructor(private readonly dbAccessor: DbAccessor) {}
async getOverview(): Promise<DashboardOverview> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const [rawStats] = await db
.select({
totalPhotos: sql<number>`count(*)`,
totalStorageBytes: sql<number>`coalesce(sum(${photoAssets.size}), 0)`,
thisMonthUploads: sql<number>`count(*) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now()))`,
previousMonthUploads: sql<number>`count(*) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now() - interval '1 month'))`,
synced: sql<number>`count(*) filter (where ${photoAssets.syncStatus} = 'synced')`,
pending: sql<number>`count(*) filter (where ${photoAssets.syncStatus} = 'pending')`,
conflicts: sql<number>`count(*) filter (where ${photoAssets.syncStatus} = 'conflict')`,
})
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
const stats = rawStats
? {
totalPhotos: Number(rawStats.totalPhotos ?? 0),
totalStorageBytes: Number(rawStats.totalStorageBytes ?? 0),
thisMonthUploads: Number(rawStats.thisMonthUploads ?? 0),
previousMonthUploads: Number(rawStats.previousMonthUploads ?? 0),
sync: {
synced: Number(rawStats.synced ?? 0),
pending: Number(rawStats.pending ?? 0),
conflicts: Number(rawStats.conflicts ?? 0),
},
}
: { ...ZERO_STATS }
const recentRecords = await db
.select({
id: photoAssets.id,
photoId: photoAssets.photoId,
createdAt: photoAssets.createdAt,
storageProvider: photoAssets.storageProvider,
manifest: photoAssets.manifest,
size: photoAssets.size,
syncStatus: photoAssets.syncStatus,
})
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
.orderBy(desc(photoAssets.createdAt))
.limit(8)
const recentActivity: DashboardRecentActivityItem[] = recentRecords.map((record) => {
const { manifest } = record
const manifestData = manifest?.data
const tags = manifestData?.tags?.slice(0, 5) ?? []
return {
id: record.id,
photoId: record.photoId,
title: manifestData?.title?.trim() || manifestData?.description?.trim() || record.photoId,
description: manifestData?.description?.trim() || null,
createdAt: record.createdAt,
takenAt: manifestData?.dateTaken ?? null,
storageProvider: record.storageProvider,
size: record.size ?? null,
syncStatus: record.syncStatus,
tags,
previewUrl: manifestData?.thumbnailUrl ?? manifestData?.originalUrl ?? null,
}
})
return {
stats,
recentActivity,
}
}
}

View File

@@ -0,0 +1,34 @@
import type { photoAssets } from '@afilmory/db'
type PhotoAssetSyncStatus = (typeof photoAssets.$inferSelect)['syncStatus']
export interface DashboardStats {
totalPhotos: number
totalStorageBytes: number
thisMonthUploads: number
previousMonthUploads: number
sync: {
synced: number
pending: number
conflicts: number
}
}
export interface DashboardRecentActivityItem {
id: string
photoId: string
title: string
description: string | null
createdAt: string
takenAt: string | null
storageProvider: string
size: number | null
syncStatus: PhotoAssetSyncStatus
tags: string[]
previewUrl: string | null
}
export interface DashboardOverview {
stats: DashboardStats
recentActivity: DashboardRecentActivityItem[]
}

View File

@@ -1263,6 +1263,7 @@ export class DataSyncService {
this.photoBuilderService.applyStorageConfig(builder, options.storageConfig)
}
this.photoStorageService.registerStorageProviderPlugin(builder, effectiveStorageConfig)
const storageManager = builder.getStorageManager()
if (payload.type === 'missing-in-storage') {

View File

@@ -8,6 +8,7 @@ import { RedisAccessor } from 'core/redis/redis.provider'
import { DatabaseModule } from '../database/database.module'
import { RedisModule } from '../redis/redis.module'
import { AuthModule } from './auth/auth.module'
import { DashboardModule } from './dashboard/dashboard.module'
import { DataSyncModule } from './data-sync/data-sync.module'
import { OnboardingModule } from './onboarding/onboarding.module'
import { PhotoModule } from './photo/photo.module'
@@ -32,6 +33,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
SuperAdminModule,
OnboardingModule,
PhotoModule,
DashboardModule,
TenantModule,
DataSyncModule,
EventModule.forRootAsync({

View File

@@ -196,7 +196,7 @@ export class PhotoAssetService {
const manifest = this.createManifestPayload(item)
const snapshot = this.createStorageSnapshot(storageObject)
const now = this.nowIso()
const now = new Date().toISOString()
const insertPayload: typeof photoAssets.$inferInsert = {
tenantId: tenant.tenant.id,
@@ -315,10 +315,6 @@ export class PhotoAssetService {
}
}
private nowIso(): string {
return new Date().toISOString()
}
private createStorageKey(input: UploadAssetInput, storageConfig: StorageConfig): string {
const ext = path.extname(input.filename)
const base = path.basename(input.filename, ext).trim()

View File

@@ -2,7 +2,7 @@ import { clsxm } from '@afilmory/utils'
import type { ReactNode } from 'react'
export const LinearBorderPanel = ({ className, children }: { className?: string; children: ReactNode }) => (
<div className={clsxm('group relative overflow-hidden -mx-6', className)}>
<div className={clsxm('group relative overflow-hidden', className)}>
{/* Linear gradient borders - sharp edges */}
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />

View File

@@ -0,0 +1,13 @@
import { coreApi } from '~/lib/api-client'
import { camelCaseKeys } from '~/lib/case'
import type { DashboardOverviewResponse } from './types'
const DASHBOARD_OVERVIEW_ENDPOINT = '/dashboard/overview'
export const fetchDashboardOverview = async () =>
camelCaseKeys<DashboardOverviewResponse>(
await coreApi<DashboardOverviewResponse>(DASHBOARD_OVERVIEW_ENDPOINT, {
method: 'GET',
}),
)

View File

@@ -0,0 +1,372 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { useDashboardOverviewQuery } from '../hooks'
import type { DashboardRecentActivityItem } from '../types'
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const plainNumberFormatter = new Intl.NumberFormat('zh-CN')
const percentFormatter = new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 1,
})
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short',
})
function formatCompactNumber(value: number) {
if (!Number.isFinite(value)) return '--'
if (value === 0) return '0'
return compactNumberFormatter.format(value)
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
type TimeDivision = {
amount: number
unit: Intl.RelativeTimeFormatUnit
}
const timeDivisions: TimeDivision[] = [
{ amount: 60, unit: 'second' },
{ amount: 60, unit: 'minute' },
{ amount: 24, unit: 'hour' },
{ amount: 7, unit: 'day' },
{ amount: 4.34524, unit: 'week' },
{ amount: 12, unit: 'month' },
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
]
function formatRelativeTime(iso: string | null | undefined) {
if (!iso) return '时间未知'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) {
return '时间未知'
}
let diffInSeconds = (date.getTime() - Date.now()) / 1000
for (const division of timeDivisions) {
if (Math.abs(diffInSeconds) < division.amount) {
return relativeTimeFormatter.format(Math.round(diffInSeconds), division.unit)
}
diffInSeconds /= division.amount
}
return dateTimeFormatter.format(date)
}
function formatTakenAt(iso: string | null) {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return dateTimeFormatter.format(date)
}
const STATUS_META = {
synced: {
label: '已同步',
barClass: 'bg-emerald-400/80',
dotClass: 'bg-emerald-400/90',
badgeClass: 'bg-emerald-500/10 text-emerald-300',
},
pending: {
label: '处理中',
barClass: 'bg-orange-400/80',
dotClass: 'bg-orange-400/90',
badgeClass: 'bg-orange-500/10 text-orange-300',
},
conflict: {
label: '需关注',
barClass: 'bg-red-500/80',
dotClass: 'bg-red-500/90',
badgeClass: 'bg-red-500/10 text-red-300',
},
} satisfies Record<
DashboardRecentActivityItem['syncStatus'],
{ label: string; barClass: string; dotClass: string; badgeClass: string }
>
const EMPTY_STATS = {
totalPhotos: 0,
totalStorageBytes: 0,
thisMonthUploads: 0,
previousMonthUploads: 0,
sync: {
synced: 0,
pending: 0,
conflicts: 0,
},
} as const
const ActivitySkeleton = () => (
<div className="bg-fill/10 animate-pulse rounded-lg border border-fill-tertiary px-3.5 py-3">
<div className="flex items-start gap-3">
<div className="bg-fill/20 h-11 w-11 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2">
<div className="bg-fill/20 h-3.5 w-32 rounded-full" />
<div className="bg-fill/15 h-3 w-48 rounded-full" />
<div className="bg-fill/15 h-3 w-40 rounded-full" />
</div>
</div>
</div>
)
const StatSkeleton = () => (
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden p-5">
<div className="space-y-2.5">
<div className="bg-fill/20 h-3 w-20 rounded-full" />
<div className="bg-fill/30 h-7 w-24 rounded-md" />
<div className="bg-fill/20 h-3 w-32 rounded-full" />
</div>
</LinearBorderPanel>
)
const ActivityList = ({ items }: { items: DashboardRecentActivityItem[] }) => {
if (items.length === 0) {
return <div className="text-text-tertiary mt-5 text-sm"></div>
}
return (
<div className="mt-5 space-y-2.5">
{items.map((item, index) => {
const statusMeta = STATUS_META[item.syncStatus]
const takenAtText = formatTakenAt(item.takenAt)
return (
<m.div
key={item.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
className="bg-fill/5 hover:bg-fill/10 group rounded-lg border border-fill-tertiary px-3.5 py-3 transition-colors duration-200"
>
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="bg-fill/10 relative h-11 w-11 shrink-0 overflow-hidden rounded-lg">
{item.previewUrl ? (
<img src={item.previewUrl} alt={item.title} className="size-full object-cover" loading="lazy" />
) : (
<div className="text-text-tertiary flex size-full items-center justify-center text-[10px]">
No Preview
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="text-text truncate text-sm font-semibold">{item.title}</div>
<div className="text-text-tertiary text-xs leading-relaxed">
<span> {formatRelativeTime(item.createdAt)}</span>
{takenAtText ? (
<>
<span className="mx-1.5"></span>
<span> {takenAtText}</span>
</>
) : null}
</div>
<div className="text-text-secondary flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span>{item.size != null && item.size > 0 ? formatBytes(item.size) : '大小未知'}</span>
<span className="text-text-tertiary"></span>
<span>{item.storageProvider}</span>
<span className="text-text-tertiary"></span>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${statusMeta.badgeClass}`}>
{statusMeta.label}
</span>
</div>
{item.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5 pt-0.5">
{item.tags.map((tag) => (
<span key={tag} className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-[10px]">
{tag}
</span>
))}
</div>
) : null}
</div>
</div>
<div className="text-text-tertiary min-w-0 truncate text-right text-[11px] sm:text-right">
ID:
<span className="ml-1 truncate">{item.photoId}</span>
</div>
</div>
</m.div>
)
})}
</div>
)
}
export const DashboardOverview = () => {
const { data, isLoading, isError } = useDashboardOverviewQuery()
const stats = data?.stats ?? EMPTY_STATS
const statusTotal = stats.sync.synced + stats.sync.pending + stats.sync.conflicts
const syncCompletion = statusTotal === 0 ? null : stats.sync.synced / statusTotal
const monthlyDelta = stats.thisMonthUploads - stats.previousMonthUploads
let monthlyTrendDescription = '与上月持平'
if (stats.previousMonthUploads === 0) {
monthlyTrendDescription = stats.thisMonthUploads === 0 ? '与上月持平' : '首次出现上传记录'
} else if (monthlyDelta > 0) {
monthlyTrendDescription = `比上月多 ${plainNumberFormatter.format(monthlyDelta)}`
} else if (monthlyDelta < 0) {
monthlyTrendDescription = `比上月少 ${plainNumberFormatter.format(Math.abs(monthlyDelta))}`
}
const averageSize = stats.totalPhotos > 0 ? stats.totalStorageBytes / stats.totalPhotos : 0
const statCards = [
{
key: 'total-photos',
label: '照片总数',
value: formatCompactNumber(stats.totalPhotos),
helper: `${plainNumberFormatter.format(stats.totalPhotos)} 张照片`,
},
{
key: 'storage',
label: '占用存储',
value: formatBytes(stats.totalStorageBytes),
helper: stats.totalPhotos > 0 ? `平均每张 ${formatBytes(averageSize || 0)}` : '暂无照片,存储占用为 0',
},
{
key: 'this-month',
label: '本月新增',
value: formatCompactNumber(stats.thisMonthUploads),
helper: monthlyTrendDescription,
},
{
key: 'sync',
label: '同步完成率',
value: syncCompletion === null ? '--' : percentFormatter.format(syncCompletion),
helper: statusTotal
? `待处理 ${plainNumberFormatter.format(stats.sync.pending)} | 冲突 ${plainNumberFormatter.format(stats.sync.conflicts)}`
: '暂无同步任务',
},
]
const syncSummary = [
{ key: 'synced', value: stats.sync.synced },
{ key: 'pending', value: stats.sync.pending },
{ key: 'conflict', value: stats.sync.conflicts },
] as const
return (
<MainPageLayout title="Dashboard" description="掌握图库运行状态与最近同步活动">
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{isLoading
? Array.from({ length: 4 }, (_, i) => `skeleton-${i}`).map((key) => <StatSkeleton key={key} />)
: statCards.map((card, index) => (
<LinearBorderPanel key={card.key} className="bg-background-tertiary/60 relative overflow-hidden p-5">
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
className="space-y-2.5"
>
<span className="text-text-secondary text-xs font-medium tracking-wide uppercase">
{card.label}
</span>
<div className="text-text text-2xl font-semibold">{card.value}</div>
<div className="text-text-tertiary text-xs leading-relaxed">{card.helper}</div>
</m.div>
</LinearBorderPanel>
))}
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
<div className="space-y-1.5">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm leading-relaxed">
{data?.recentActivity?.length
? `展示最近 ${data.recentActivity.length} 次上传和同步记录`
: '还没有任何上传,快来添加第一张照片吧~'}
</p>
</div>
{isLoading ? (
<div className="mt-5 space-y-2.5">
{Array.from({ length: 3 }, (_, i) => `activity-skeleton-${i}`).map((key) => (
<ActivitySkeleton key={key} />
))}
</div>
) : isError ? (
<div className="text-red-400 mt-5 text-sm"></div>
) : (
<ActivityList items={data?.recentActivity ?? []} />
)}
</LinearBorderPanel>
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
<div className="space-y-1.5">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm leading-relaxed"></p>
</div>
<div className="mt-5 space-y-4">
{syncSummary.map((entry) => {
const meta = STATUS_META[entry.key]
const percent = statusTotal ? Math.round((entry.value / statusTotal) * 100) : 0
return (
<div key={entry.key} className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-text-secondary inline-flex items-center gap-1.5">
<span className={`size-2 rounded-full ${meta.dotClass}`} />
{meta.label}
</span>
<span className="text-text font-medium">
{plainNumberFormatter.format(entry.value)}
<span className="text-text-tertiary ml-1.5">{percent}%</span>
</span>
</div>
<div className="bg-fill/20 h-1.5 w-full overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-300 ${meta.barClass}`}
style={{ width: `${statusTotal ? Math.max(percent, entry.value > 0 ? 4 : 0) : 0}%` }}
/>
</div>
</div>
)
})}
</div>
<div className="text-text-secondary mt-5 rounded-lg border border-fill-tertiary bg-fill/5 px-3.5 py-2.5 text-xs leading-relaxed">
{statusTotal === 0
? '暂无同步任务,添加照片后即可查看同步健康度。'
: syncCompletion !== null && syncCompletion >= 0.85
? '同步状态良好,保持当前处理效率即可。'
: '存在待处理或冲突的项目,建议尽快检查同步日志。'}
</div>
</LinearBorderPanel>
</div>
</div>
</MainPageLayout>
)
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { fetchDashboardOverview } from './api'
import type { DashboardOverviewResponse } from './types'
export const DASHBOARD_OVERVIEW_QUERY_KEY = ['dashboard', 'overview'] as const
export const useDashboardOverviewQuery = () =>
useQuery<DashboardOverviewResponse>({
queryKey: DASHBOARD_OVERVIEW_QUERY_KEY,
queryFn: fetchDashboardOverview,
staleTime: 60 * 1000,
})

View File

@@ -0,0 +1,30 @@
export interface DashboardStats {
totalPhotos: number
totalStorageBytes: number
thisMonthUploads: number
previousMonthUploads: number
sync: {
synced: number
pending: number
conflicts: number
}
}
export interface DashboardRecentActivityItem {
id: string
photoId: string
title: string
description: string | null
createdAt: string
takenAt: string | null
storageProvider: string
size: number | null
syncStatus: 'pending' | 'synced' | 'conflict'
tags: string[]
previewUrl: string | null
}
export interface DashboardOverviewResponse {
stats: DashboardStats
recentActivity: DashboardRecentActivityItem[]
}

View File

@@ -1,68 +1,3 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { DashboardOverview } from '~/modules/dashboard/components/DashboardOverview'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
export const Component = () => {
return (
<MainPageLayout title="Dashboard" description="Welcome to your photo management dashboard">
<div className="space-y-6">
{/* Stats Cards - Sharp Edges */}
<div className="grid gap-4 md:grid-cols-3">
{[
{ label: 'Total Photos', value: '1,234', trend: '+12%' },
{ label: 'Storage Used', value: '45.2 GB', trend: '+8%' },
{ label: 'This Month', value: '156', trend: '+24%' },
].map((stat, index) => (
<m.div
key={stat.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.1 }}
className="bg-background-tertiary relative p-4"
>
{/* Gradient borders */}
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
<div className="text-text-secondary text-[11px] font-medium">{stat.label}</div>
<div className="text-text mt-2 text-2xl font-semibold">{stat.value}</div>
<div className="text-accent mt-1 text-[11px] font-medium">{stat.trend} from last month</div>
</m.div>
))}
</div>
{/* Recent Activity - Sharp Edges */}
<div className="bg-background-tertiary relative p-4">
{/* Gradient borders */}
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
<h2 className="text-text text-sm font-semibold">Recent Activity</h2>
<div className="mt-4 space-y-2">
{[
{ action: 'Uploaded 23 photos', time: '2 hours ago' },
{
action: 'Created new album "Summer 2024"',
time: '5 hours ago',
},
{ action: 'Shared album with 3 people', time: '1 day ago' },
].map((activity) => (
<div
key={activity.action}
className="bg-fill/10 hover:bg-fill/20 flex items-center justify-between px-3 py-2 transition-colors"
>
<span className="text-text text-[13px]">{activity.action}</span>
<span className="text-text-tertiary text-[11px]">{activity.time}</span>
</div>
))}
</div>
</div>
</div>
</MainPageLayout>
)
}
export const Component = () => <DashboardOverview />