feat(analytics): implement dashboard analytics components and API integration

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-02 22:32:00 +08:00
parent 57c9642a98
commit d5b20a630f
9 changed files with 668 additions and 85 deletions

View File

@@ -12,4 +12,9 @@ export class DashboardController {
async getOverview() {
return await this.dashboardService.getOverview()
}
@Get('analytics')
async getAnalytics() {
return await this.dashboardService.getAnalytics()
}
}

View File

@@ -4,7 +4,12 @@ import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { requireTenantContext } from '../tenant/tenant.context'
import type { DashboardOverview, DashboardRecentActivityItem } from './dashboard.types'
import type {
DashboardAnalytics,
DashboardOverview,
DashboardRecentActivityItem,
DashboardStorageProviderUsage,
} from './dashboard.types'
const ZERO_STATS = {
totalPhotos: 0,
@@ -93,4 +98,140 @@ export class DashboardService {
recentActivity,
}
}
async getAnalytics(): Promise<DashboardAnalytics> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const uploadTrendResult = await db.execute<{ month: string | null; uploads: number | null }>(sql`
with months as (
select generate_series(
date_trunc('month', now()) - interval '11 months',
date_trunc('month', now()),
interval '1 month'
) as month_start
)
select to_char(months.month_start, 'YYYY-MM') as month,
coalesce(upload_counts.uploads, 0)::int as uploads
from months
left join (
select date_trunc('month', ${photoAssets.createdAt}) as month_start,
count(*)::int as uploads
from ${photoAssets}
where ${photoAssets.tenantId} = ${tenant.tenant.id}
and ${photoAssets.createdAt} >= date_trunc('month', now()) - interval '11 months'
group by month_start
) as upload_counts on upload_counts.month_start = months.month_start
order by months.month_start
`)
const uploadTrends = uploadTrendResult.rows.map((row) => ({
month: row.month ?? '',
uploads: Number(row.uploads ?? 0),
}))
const [storageAggregate] = await db
.select({
totalBytes: sql<number>`coalesce(sum(${photoAssets.size}), 0)`,
totalPhotos: sql<number>`count(*)`,
currentMonthBytes: sql<number>`coalesce(sum(${photoAssets.size}) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now())), 0)`,
previousMonthBytes: sql<number>`coalesce(sum(${photoAssets.size}) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now() - interval '1 month')), 0)`,
})
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
const providerUsageRaw = await db
.select({
provider: photoAssets.storageProvider,
bytes: sql<number>`coalesce(sum(${photoAssets.size}), 0)`,
photoCount: sql<number>`count(*)`,
})
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
.groupBy(photoAssets.storageProvider)
const providers: DashboardStorageProviderUsage[] = providerUsageRaw
.map((entry) => ({
provider: (entry.provider ?? 'unknown').trim() || 'unknown',
bytes: Number(entry.bytes ?? 0),
photoCount: Number(entry.photoCount ?? 0),
}))
.sort((a, b) => b.bytes - a.bytes)
const popularTagsResult = await db.execute<{ tag: string | null; count: number | null }>(sql`
select tag, count(*)::int as count
from (
select nullif(trim(jsonb_array_elements_text(${photoAssets.manifest}->'data'->'tags')), '') as tag
from ${photoAssets}
where ${photoAssets.tenantId} = ${tenant.tenant.id}
) as tag_items
where tag is not null
group by tag
order by count desc
limit 8
`)
const popularTags = popularTagsResult.rows
.map((row) => {
const tag = row.tag?.trim()
if (!tag) {
return null
}
return {
tag,
count: Number(row.count ?? 0),
}
})
.filter((value): value is { tag: string; count: number } => value !== null)
const topDevicesRaw = await db.execute<{ make: string | null; model: string | null; count: number | null }>(sql`
select
nullif(trim(${photoAssets.manifest}::jsonb #>> '{data,exif,Make}'), '') as make,
nullif(trim(${photoAssets.manifest}::jsonb #>> '{data,exif,Model}'), '') as model,
count(*)::int as count
from ${photoAssets}
where ${photoAssets.tenantId} = ${tenant.tenant.id}
group by make, model
order by count desc
limit 20
`)
const deviceCounter = new Map<string, number>()
for (const row of topDevicesRaw.rows) {
const make = row.make ?? ''
const model = row.model ?? ''
let name = model || make
if (make && model) {
const lowerMake = make.toLowerCase()
const lowerModel = model.toLowerCase()
name = lowerModel.includes(lowerMake) ? model : `${make} ${model}`
}
name = name.trim()
if (!name) {
continue
}
const existing = deviceCounter.get(name) ?? 0
deviceCounter.set(name, existing + Number(row.count ?? 0))
}
const topDevices = Array.from(deviceCounter.entries())
.map(([device, count]) => ({ device, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 8)
return {
uploadTrends,
storageUsage: {
totalBytes: Number(storageAggregate?.totalBytes ?? 0),
totalPhotos: Number(storageAggregate?.totalPhotos ?? 0),
currentMonthBytes: Number(storageAggregate?.currentMonthBytes ?? 0),
previousMonthBytes: Number(storageAggregate?.previousMonthBytes ?? 0),
providers,
},
popularTags,
topDevices,
}
}
}

View File

@@ -32,3 +32,39 @@ export interface DashboardOverview {
stats: DashboardStats
recentActivity: DashboardRecentActivityItem[]
}
export interface DashboardUploadTrendPoint {
month: string
uploads: number
}
export interface DashboardStorageProviderUsage {
provider: string
bytes: number
photoCount: number
}
export interface DashboardStorageUsage {
totalBytes: number
totalPhotos: number
currentMonthBytes: number
previousMonthBytes: number
providers: DashboardStorageProviderUsage[]
}
export interface DashboardTagStat {
tag: string
count: number
}
export interface DashboardDeviceStat {
device: string
count: number
}
export interface DashboardAnalytics {
uploadTrends: DashboardUploadTrendPoint[]
storageUsage: DashboardStorageUsage
popularTags: DashboardTagStat[]
topDevices: DashboardDeviceStat[]
}

View File

@@ -0,0 +1,14 @@
import { coreApi } from '~/lib/api-client'
import { camelCaseKeys } from '~/lib/case'
import type { DashboardAnalyticsResponse } from './types'
const DASHBOARD_ANALYTICS_ENDPOINT = '/dashboard/analytics'
export async function fetchDashboardAnalytics() {
return camelCaseKeys<DashboardAnalyticsResponse>(
await coreApi<DashboardAnalyticsResponse>(DASHBOARD_ANALYTICS_ENDPOINT, {
method: 'GET',
}),
)
}

View File

@@ -0,0 +1,418 @@
import { clsxm, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { useDashboardAnalyticsQuery } from '../hooks'
import type { StorageProviderUsage, UploadTrendPoint } from '../types'
const plainNumberFormatter = new Intl.NumberFormat('zh-CN')
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const percentFormatter = new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 1,
})
const monthLabelFormatter = new Intl.DateTimeFormat('zh-CN', { month: 'short' })
const fullMonthFormatter = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' })
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
const fixed = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)
return `${fixed} ${units[unitIndex]}`
}
function buildMonthDate(month: string) {
const [yearStr, monthStr] = month.split('-')
const year = Number.parseInt(yearStr, 10)
const monthIndex = Number.parseInt(monthStr, 10) - 1
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || monthIndex < 0 || monthIndex > 11) {
return null
}
return new Date(Date.UTC(year, monthIndex, 1))
}
function formatMonthLabel(month: string) {
const date = buildMonthDate(month)
return date ? monthLabelFormatter.format(date) : month
}
function formatFullMonth(month: string) {
const date = buildMonthDate(month)
return date ? fullMonthFormatter.format(date) : month
}
function TrendSkeleton() {
return (
<div className="mt-6">
<div className="flex h-44 items-end gap-2">
{Array.from({ length: 12 }, (_, index) => (
<div key={index} className="flex flex-1 flex-col items-center gap-1">
<div className="bg-fill/15 relative flex h-40 w-full items-end overflow-hidden rounded-md">
<div className="bg-fill/25 mb-0 w-full rounded-md" style={{ height: `${20 + (index % 3) * 10}%` }} />
</div>
<div className="bg-fill/20 h-3 w-6 rounded-full" />
<div className="bg-fill/15 h-3 w-8 rounded-full" />
</div>
))}
</div>
</div>
)
}
function ProvidersSkeleton() {
return (
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="space-y-2">
<div className="bg-fill/20 h-3 w-36 rounded-full" />
<div className="bg-fill/15 h-2.5 w-full rounded-full" />
</div>
))}
</div>
)
}
function RankedListSkeleton() {
return (
<div className="mt-4 space-y-2">
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="flex items-center justify-between">
<div className="bg-fill/20 h-3 w-32 rounded-full" />
<div className="bg-fill/15 h-3 w-12 rounded-full" />
</div>
))}
</div>
)
}
function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
const maxUploads = data.reduce((max, point) => Math.max(max, point.uploads), 0)
return (
<div className="mt-6 overflow-x-auto pb-2">
<div className="flex h-44 min-w-[480px] items-end gap-3">
{data.map((point, index) => {
const basePercent = maxUploads === 0 ? 0 : (point.uploads / maxUploads) * 100
const heightPercent = Math.max(basePercent, point.uploads > 0 ? 8 : 0)
const monthLabel = formatMonthLabel(point.month)
const fullLabel = formatFullMonth(point.month)
return (
<m.div
key={point.month}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
className="group flex min-w-[32px] flex-1 flex-col items-center gap-1"
title={`${fullLabel} · ${plainNumberFormatter.format(point.uploads)}`}
>
<div className="bg-fill/15 relative flex h-40 w-full items-end overflow-hidden rounded-md">
<div
className="bg-accent/70 group-hover:bg-accent absolute bottom-0 left-0 right-0 rounded-md transition-colors duration-200"
style={{ height: `${heightPercent}%` }}
/>
</div>
<span className="text-text-tertiary text-[11px] leading-none">{monthLabel}</span>
<span className="text-text-secondary text-[11px] leading-none">
{plainNumberFormatter.format(point.uploads)}
</span>
</m.div>
)
})}
</div>
</div>
)
}
function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUsage[]; totalBytes: number }) {
if (providers.length === 0) {
return <div className="text-text-tertiary mt-5 text-sm">使</div>
}
return (
<div className="mt-5 space-y-3">
{providers.map((provider, index) => {
const ratio = totalBytes > 0 ? provider.bytes / totalBytes : 0
const percent = Math.round(ratio * 100)
return (
<m.div
key={provider.provider || index}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
className="space-y-1.5"
>
<div className="flex items-center justify-between text-sm">
<span className="text-text capitalize">{provider.provider}</span>
<span className="text-text-secondary">
{formatBytes(provider.bytes)}
<span className="text-text-tertiary ml-2">
{percent}% · {provider.photoCount}
</span>
</span>
</div>
<div className="bg-fill/15 h-2.5 w-full rounded-full">
<div
className="bg-accent/70 h-full rounded-full"
style={{ width: `${Math.min(Math.max(percent, 2), 100)}%` }}
/>
</div>
</m.div>
)
})}
</div>
)
}
function RankedList({ items, emptyText }: { items: Array<{ label: string; value: number }>; emptyText: string }) {
if (items.length === 0) {
return <div className="text-text-tertiary mt-4 text-sm">{emptyText}</div>
}
const maxValue = items.reduce((max, item) => Math.max(max, item.value), 0)
return (
<div className="mt-4 space-y-2.5">
{items.map((item, index) => {
const ratio = maxValue > 0 ? item.value / maxValue : 0
return (
<m.div
key={item.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
className="flex items-center gap-3 text-sm"
>
<div className="text-text-tertiary w-6 text-right text-[11px]">#{index + 1}</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-text truncate">{item.label}</span>
<span className="text-text-secondary text-[13px]">{plainNumberFormatter.format(item.value)}</span>
</div>
<div className="bg-fill/15 mt-1.5 h-2 rounded-full">
<div
className="bg-accent/60 h-full rounded-full"
style={{ width: `${Math.min(Math.max(ratio * 100, 4), 100)}%` }}
/>
</div>
</div>
</m.div>
)
})}
</div>
)
}
function SectionPanel({
title,
description,
className,
children,
}: {
title: string
description: string
className?: string
children: ReactNode
}) {
return (
<LinearBorderPanel className={clsxm('bg-background-tertiary p-5', className)}>
<div className="space-y-2">
<h2 className="text-text text-sm font-semibold">{title}</h2>
<p className="text-text-tertiary text-[13px] leading-relaxed">{description}</p>
</div>
{children}
</LinearBorderPanel>
)
}
export function DashboardAnalytics() {
const { data, isLoading, isError } = useDashboardAnalyticsQuery()
const uploadTrendStats = useMemo(() => {
if (!data?.uploadTrends?.length) {
return null
}
const totalUploads = data.uploadTrends.reduce((sum, point) => sum + point.uploads, 0)
const bestMonth = data.uploadTrends.reduce(
(best, current) => (current.uploads > best.uploads ? current : best),
data.uploadTrends[0],
)
const currentMonth = data.uploadTrends.at(-1)!
const previousMonth = data.uploadTrends.length > 1 ? (data.uploadTrends.at(-2) ?? null) : null
const delta = previousMonth ? currentMonth.uploads - previousMonth.uploads : currentMonth.uploads
const growth = previousMonth && previousMonth.uploads > 0 ? delta / previousMonth.uploads : null
return {
totalUploads,
bestMonth,
currentMonth,
previousMonth,
delta,
growth,
}
}, [data?.uploadTrends])
const storageUsage = data?.storageUsage
const popularTagItems = data?.popularTags?.map((entry) => ({
label: entry.tag,
value: entry.count,
}))
const deviceItems = data?.topDevices?.map((entry) => ({
label: entry.device,
value: entry.count,
}))
return (
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
<div className="grid gap-4 md:grid-cols-2">
<SectionPanel title="Upload Trends" description="近 12 个月的上传趋势">
{isLoading ? (
<TrendSkeleton />
) : isError ? (
<div className="text-red mt-6 text-sm"></div>
) : data?.uploadTrends?.length ? (
<>
{uploadTrendStats ? (
<div className="border-fill/20 mt-5 grid gap-3 rounded-lg border bg-fill/5 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{compactNumberFormatter.format(uploadTrendStats.totalUploads)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{formatFullMonth(uploadTrendStats.bestMonth.month)}
<span className="text-text-tertiary ml-2 text-[13px]">
{plainNumberFormatter.format(uploadTrendStats.bestMonth.uploads)}
</span>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{plainNumberFormatter.format(uploadTrendStats.currentMonth.uploads)}
{uploadTrendStats.growth !== null ? (
<span
className={clsxm(
'ml-2 text-[13px]',
uploadTrendStats.growth >= 0 ? 'text-emerald-300' : 'text-red-300',
)}
>
{uploadTrendStats.growth === 0
? '与上月持平'
: `${uploadTrendStats.growth >= 0 ? '+' : ''}${percentFormatter.format(uploadTrendStats.growth)}`}
</span>
) : uploadTrendStats.previousMonth ? (
<span className="text-text-tertiary ml-2 text-[13px]">
{uploadTrendStats.delta > 0 ? '首次出现上传记录' : '与上月持平'}
</span>
) : null}
</span>
</div>
</div>
) : null}
<UploadTrendsChart data={data.uploadTrends} />
</>
) : (
<div className="text-text-tertiary mt-6 text-sm"></div>
)}
</SectionPanel>
<SectionPanel title="Storage Usage" description="按存储提供方统计的容量占比">
{isLoading ? (
<ProvidersSkeleton />
) : isError ? (
<div className="text-red mt-5 text-sm"></div>
) : storageUsage ? (
(() => {
const monthDeltaBytes = storageUsage.currentMonthBytes - storageUsage.previousMonthBytes
let monthDeltaDescription = '与上月持平'
if (storageUsage.previousMonthBytes > 0) {
if (monthDeltaBytes !== 0) {
const prefix = monthDeltaBytes > 0 ? '+' : '-'
monthDeltaDescription = `${prefix}${formatBytes(Math.abs(monthDeltaBytes))} 对比上月`
}
} else if (storageUsage.currentMonthBytes > 0) {
monthDeltaDescription = '首次记录'
}
return (
<>
<div className="border-fill/20 mt-5 grid gap-3 rounded-lg border bg-fill/5 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">{formatBytes(storageUsage.totalBytes)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{plainNumberFormatter.format(storageUsage.totalPhotos)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-text-secondary"></span>
<span className="text-text font-semibold">
{formatBytes(storageUsage.currentMonthBytes)}
<span className="text-text-tertiary ml-2 text-[13px]">{monthDeltaDescription}</span>
</span>
</div>
</div>
<ProvidersList providers={storageUsage.providers} totalBytes={storageUsage.totalBytes} />
</>
)
})()
) : (
<div className="text-text-tertiary mt-5 text-sm">使</div>
)}
</SectionPanel>
<SectionPanel title="Popular Tags" description="最近上传中最常使用的标签">
{isLoading ? (
<RankedListSkeleton />
) : isError ? (
<div className="text-red mt-4 text-sm"></div>
) : (
<RankedList items={popularTagItems ?? []} emptyText="暂无标签统计数据。" />
)}
</SectionPanel>
<SectionPanel title="Top Devices" description="根据 EXIF 信息统计的热门拍摄设备">
{isLoading ? (
<RankedListSkeleton />
) : isError ? (
<div className="text-red mt-4 text-sm"></div>
) : (
<RankedList items={deviceItems ?? []} emptyText="暂无设备统计数据。" />
)}
</SectionPanel>
</div>
</MainPageLayout>
)
}

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import { fetchDashboardAnalytics } from './api'
import type { DashboardAnalyticsResponse } from './types'
export const DASHBOARD_ANALYTICS_QUERY_KEY = ['dashboard', 'analytics'] as const
export function useDashboardAnalyticsQuery() {
return useQuery<DashboardAnalyticsResponse>({
queryKey: DASHBOARD_ANALYTICS_QUERY_KEY,
queryFn: fetchDashboardAnalytics,
staleTime: 60 * 1000,
})
}

View File

@@ -0,0 +1,35 @@
export interface UploadTrendPoint {
month: string
uploads: number
}
export interface StorageProviderUsage {
provider: string
bytes: number
photoCount: number
}
export interface StorageUsageSummary {
totalBytes: number
totalPhotos: number
currentMonthBytes: number
previousMonthBytes: number
providers: StorageProviderUsage[]
}
export interface PopularTagStat {
tag: string
count: number
}
export interface DeviceStat {
device: string
count: number
}
export interface DashboardAnalyticsResponse {
uploadTrends: UploadTrendPoint[]
storageUsage: StorageUsageSummary
popularTags: PopularTagStat[]
topDevices: DeviceStat[]
}

View File

@@ -1,83 +1,3 @@
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { DashboardAnalytics } from '~/modules/analytics/components/AnalyticsPage'
export function Component() {
return (
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
<div className="grid gap-4 md:grid-cols-2">
{/* Upload Trends */}
<div className="bg-background-tertiary relative p-5">
<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 mb-4 text-sm font-semibold">Upload Trends</h2>
<div className="text-text-tertiary flex h-64 items-center justify-center text-[13px]">Chart placeholder</div>
</div>
{/* Storage Usage */}
<div className="bg-background-tertiary relative p-5">
<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 mb-4 text-sm font-semibold">Storage Usage</h2>
<div className="text-text-tertiary flex h-64 items-center justify-center text-[13px]">Chart placeholder</div>
</div>
{/* Popular Tags */}
<div className="bg-background-tertiary relative p-5">
<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 mb-4 text-sm font-semibold">Popular Tags</h2>
<div className="space-y-1.5">
{[
{ tag: 'Nature', count: 234 },
{ tag: 'Travel', count: 189 },
{ tag: 'Portrait', count: 156 },
{ tag: 'Architecture', count: 142 },
].map((item) => (
<div
key={item.tag}
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]">{item.tag}</span>
<span className="text-accent text-[13px] font-medium">{item.count}</span>
</div>
))}
</div>
</div>
{/* Device Stats */}
<div className="bg-background-tertiary relative p-5">
<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 mb-4 text-sm font-semibold">Top Devices</h2>
<div className="space-y-1.5">
{[
{ device: 'iPhone 15 Pro', count: 456 },
{ device: 'Canon EOS R5', count: 342 },
{ device: 'Sony A7 IV', count: 287 },
{ device: 'Fujifilm X-T5', count: 149 },
].map((item) => (
<div
key={item.device}
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]">{item.device}</span>
<span className="text-accent text-[13px] font-medium">{item.count}</span>
</div>
))}
</div>
</div>
</div>
</MainPageLayout>
)
}
export const Component = () => <DashboardAnalytics />

View File

@@ -97,7 +97,7 @@ export function Component() {
</Button>
{/* Additional Links */}
<div className="mt-6 flex items-center justify-between text-sm">
{/* <div className="mt-6 flex items-center justify-between text-sm">
<Button
type="button"
variant="ghost"
@@ -116,7 +116,7 @@ export function Component() {
>
Create account
</Button>
</div>
</div> */}
</div>
</form>
</LinearBorderContainer>