feat: enhance tenant management with photo handling and UI improvements

- Added a new endpoint in SuperAdminTenantController to fetch tenant photos.
- Introduced TenantDetailModal for displaying tenant details and associated photos.
- Created TenantUsageCell component to visualize tenant usage metrics.
- Updated localization files to include new keys for tenant photo management.
- Enhanced SuperAdminTenantManager to support modal interactions for tenant details.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-20 22:59:04 +08:00
parent a1710924a8
commit 2d7b4e316f
15 changed files with 588 additions and 156 deletions

View File

@@ -1,9 +1,12 @@
import { Body, Controller, Get, Param, Patch } from '@afilmory/framework'
import { photoAssets } from '@afilmory/db'
import { Body, Controller, Get, Param, Patch, Query } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { Roles } from 'core/guards/roles.decorator'
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
import { BillingPlanService } from 'core/modules/platform/billing/billing-plan.service'
import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service'
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
import { desc, eq } from 'drizzle-orm'
import type { BillingPlanId } from '../billing/billing-plan.types'
import { UpdateTenantBanDto, UpdateTenantPlanDto } from './super-admin.dto'
@@ -16,8 +19,27 @@ export class SuperAdminTenantController {
private readonly tenantService: TenantService,
private readonly billingPlanService: BillingPlanService,
private readonly billingUsageService: BillingUsageService,
private readonly db: DbAccessor,
) {}
@Get('/:tenantId/photos')
async getTenantPhotos(@Param('tenantId') tenantId: string, @Query('limit') limit = '20') {
const photos = await this.db
.get()
.select()
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenantId))
.limit(Number(limit))
.orderBy(desc(photoAssets.createdAt))
return {
photos: photos.map((p) => ({
...p,
publicUrl: p.manifest.data.thumbnailUrl,
})),
}
}
@Get('/')
async listTenants() {
const [tenantAggregates, plans] = await Promise.all([

View File

@@ -1,4 +1,5 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
import { BillingModule } from 'core/modules/platform/billing/billing.module'
@@ -9,7 +10,7 @@ import { SuperAdminSettingController } from './super-admin-settings.controller'
import { SuperAdminTenantController } from './super-admin-tenants.controller'
@Module({
imports: [SystemSettingModule, BillingModule, TenantModule],
imports: [SystemSettingModule, BillingModule, TenantModule, DatabaseModule],
controllers: [SuperAdminSettingController, SuperAdminBuilderDebugController, SuperAdminTenantController],
providers: [PhotoBuilderService],
})

View File

@@ -19,6 +19,7 @@
"hooks": "~/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
"@animate-ui": "https://animate-ui.com/r/{name}.json",
"@coss": "https://coss.com/ui/r/{name}.json"
}
}

View File

@@ -19,6 +19,7 @@
"@afilmory/hooks": "workspace:*",
"@afilmory/ui": "workspace:*",
"@afilmory/utils": "workspace:*",
"@base-ui-components/react": "1.0.0-beta.6",
"@creem_io/better-auth": "0.0.8",
"@essentials/request-timeout": "1.3.0",
"@headlessui/react": "2.2.9",

View File

@@ -0,0 +1,73 @@
'use client'
import { clsxm as cn } from '@afilmory/utils'
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs'
type TabsVariant = 'default' | 'underline'
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
className={cn('flex flex-col gap-2 data-[orientation=vertical]:flex-row', className)}
data-slot="tabs"
{...props}
/>
)
}
function TabsList({
variant = 'default',
className,
children,
...props
}: TabsPrimitive.List.Props & {
variant?: TabsVariant
}) {
return (
<TabsPrimitive.List
className={cn(
'relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground',
'data-[orientation=vertical]:flex-col',
variant === 'default'
? 'rounded-lg bg-muted p-0.5 text-muted-foreground/64'
: 'data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-trigger]:hover:bg-accent',
className,
)}
data-slot="tabs-list"
{...props}
>
{children}
<TabsPrimitive.Indicator
className={cn(
'-translate-y-(--active-tab-bottom) absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) transition-[width,translate] duration-200 ease-in-out',
variant === 'underline'
? 'data-[orientation=vertical]:-translate-x-px z-10 bg-primary text-white data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=horizontal]:translate-y-px'
: '-z-1 rounded-md bg-background shadow-sm dark:bg-accent text-white',
)}
data-slot="tab-indicator"
/>
</TabsPrimitive.List>
)
}
function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
className={cn(
"flex flex-1 shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-sm outline-none transition-[color,background-color,box-shadow] focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-disabled:opacity-64 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
'hover:text-muted-foreground data-selected:text-white data-active:text-white',
'gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1.5)-1px)]',
'data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start',
className,
)}
data-slot="tabs-trigger"
{...props}
/>
)
}
function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) {
return <TabsPrimitive.Panel className={cn('flex-1 outline-none', className)} data-slot="tabs-content" {...props} />
}
export { Tabs, TabsPanel as TabsContent, TabsList, TabsPanel, TabsTab, TabsTab as TabsTrigger }

View File

@@ -8,6 +8,7 @@ import type {
BuilderDebugResult,
SuperAdminSettingsResponse,
SuperAdminTenantListResponse,
SuperAdminTenantPhotosResponse,
UpdateSuperAdminSettingsPayload,
UpdateTenantBanPayload,
UpdateTenantPlanPayload,
@@ -177,3 +178,10 @@ export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugO
return camelCaseKeys<BuilderDebugResult>(finalResult)
}
export async function fetchSuperAdminTenantPhotos(tenantId: string): Promise<SuperAdminTenantPhotosResponse> {
const response = await coreApi<SuperAdminTenantPhotosResponse>(`${SUPER_ADMIN_TENANTS_ENDPOINT}/${tenantId}/photos`, {
method: 'GET',
})
return camelCaseKeys<SuperAdminTenantPhotosResponse>(response)
}

View File

@@ -1,17 +1,16 @@
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
import { Button, Modal, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { RefreshCcwIcon } from 'lucide-react'
import { m } from 'motion/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import { BILLING_USAGE_EVENT_CONFIG } from '~/modules/photos/constants'
import type { BillingUsageEventType } from '~/modules/photos/types'
import { useSuperAdminTenantsQuery, useUpdateTenantBanMutation, useUpdateTenantPlanMutation } from '../hooks'
import type { BillingPlanDefinition, SuperAdminTenantSummary } from '../types'
import { TenantDetailModal } from './TenantDetailModal'
import { TenantUsageCell } from './TenantUsageCell'
const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
@@ -93,25 +92,23 @@ export function SuperAdminTenantManager() {
return (
<m.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={Spring.presets.smooth}>
<LinearBorderPanel className="p-6 bg-background-secondary">
<header className="flex items-center justify-end gap-3">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1"
onClick={() => tenantsQuery.refetch()}
disabled={tenantsQuery.isFetching}
>
<RefreshCcwIcon className="size-4" />
<span>
{tenantsQuery.isFetching
? t('superadmin.tenants.refresh.loading')
: t('superadmin.tenants.refresh.button')}
</span>
</Button>
</header>
<header className="flex items-center justify-end gap-3">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1"
onClick={() => tenantsQuery.refetch()}
disabled={tenantsQuery.isFetching}
>
<RefreshCcwIcon className="size-4" />
<span>
{tenantsQuery.isFetching ? t('superadmin.tenants.refresh.loading') : t('superadmin.tenants.refresh.button')}
</span>
</Button>
</header>
<LinearBorderPanel className="p-6 bg-background-secondary mt-4">
{tenants.length === 0 ? (
<p className="text-text-secondary text-sm">{t('superadmin.tenants.empty')}</p>
) : (
@@ -130,11 +127,11 @@ export function SuperAdminTenantManager() {
<tbody className="divide-y divide-border/20">
{tenants.map((tenant) => (
<tr key={tenant.id}>
<td className="px-3 py-3">
<td className="px-3 py-3 align-top">
<div className="font-medium text-text">{tenant.name}</div>
<div className="text-text-secondary text-xs">{tenant.slug}</div>
</td>
<td className="px-3 py-3">
<td className="px-3 py-3 align-top">
<PlanSelector
value={tenant.planId}
plans={plans}
@@ -143,12 +140,24 @@ export function SuperAdminTenantManager() {
/>
</td>
<td className="px-3 py-3 align-top">
<TenantUsageCell usageTotals={tenant.usageTotals} />
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => Modal.present(TenantDetailModal, { tenant })}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
Modal.present(TenantDetailModal, { tenant })
}
}}
role="button"
tabIndex={0}
>
<TenantUsageCell usageTotals={tenant.usageTotals} />
</div>
</td>
<td className="px-3 py-3 text-center align-middle">
<td className="px-3 py-3 text-center align-top">
<StatusBadge status={tenant.status} banned={tenant.banned} />
</td>
<td className="px-3 py-3 text-center align-middle">
<td className="px-3 py-1 text-center align-top">
<Button
type="button"
size="sm"
@@ -164,7 +173,9 @@ export function SuperAdminTenantManager() {
: t('superadmin.tenants.button.ban')}
</Button>
</td>
<td className="px-3 py-3 text-text-secondary text-xs">{formatDateLabel(tenant.createdAt)}</td>
<td className="px-3 py-3 align-top text-text-secondary text-xs">
{formatDateLabel(tenant.createdAt)}
</td>
</tr>
))}
</tbody>
@@ -207,62 +218,6 @@ function PlanSelector({
)
}
function TenantUsageCell({ usageTotals }: { usageTotals: SuperAdminTenantSummary['usageTotals'] }) {
const { t, i18n } = useTranslation()
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
const numberFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 1,
}),
[locale],
)
const entries = useMemo(() => {
const totalsMap = new Map<BillingUsageEventType, { total: number; unit: 'count' | 'byte' }>()
usageTotals?.forEach((entry) => {
totalsMap.set(entry.eventType as BillingUsageEventType, {
total: entry.totalQuantity ?? 0,
unit: entry.unit,
})
})
return (Object.keys(BILLING_USAGE_EVENT_CONFIG) as BillingUsageEventType[]).map((eventType) => {
const config = BILLING_USAGE_EVENT_CONFIG[eventType]
const usage = totalsMap.get(eventType)
return {
eventType,
label: t(config.labelKey),
tone: config.tone,
total: usage?.total ?? 0,
unit: usage?.unit ?? 'count',
}
})
}, [usageTotals, t])
const activeEntries = entries.filter((entry) => entry.total > 0)
if (activeEntries.length === 0) {
return <p className="text-text-tertiary text-xs">{t('superadmin.tenants.usage.empty')}</p>
}
return (
<div className="flex flex-wrap gap-1.5">
{activeEntries.map((entry) => (
<UsageBadge
key={`${entry.eventType}`}
label={entry.label}
tone={entry.tone}
value={entry.total}
unit={entry.unit}
formatter={numberFormatter}
/>
))}
</div>
)
}
function PlanDescription({ plan }: { plan: BillingPlanDefinition | undefined }) {
if (!plan) {
return null
@@ -270,56 +225,6 @@ function PlanDescription({ plan }: { plan: BillingPlanDefinition | undefined })
return <p className="text-text-tertiary text-xs">{plan.description}</p>
}
type UsageBadgeProps = {
label: string
tone: (typeof BILLING_USAGE_EVENT_CONFIG)[BillingUsageEventType]['tone']
value: number
unit: 'count' | 'byte'
formatter: Intl.NumberFormat
}
function UsageBadge({ label, tone, value, unit, formatter }: UsageBadgeProps) {
const toneClass =
tone === 'accent'
? 'bg-emerald-500/10 text-emerald-200 border-emerald-500/30'
: tone === 'warning'
? 'bg-rose-500/10 text-rose-200 border-rose-500/30'
: 'bg-fill/30 text-text-secondary border-border/40'
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${toneClass}`}
>
<span className="uppercase tracking-wide text-[10px] text-text-tertiary/80">{label}</span>
<span className="text-xs font-semibold text-text">{formatUsageValue(value, unit, formatter)}</span>
</span>
)
}
function formatUsageValue(value: number, unit: 'count' | 'byte', formatter: Intl.NumberFormat): string {
if (!Number.isFinite(value)) {
return '0'
}
if (unit === 'byte') {
return formatBytes(value)
}
return formatter.format(value)
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
}
function StatusBadge({ status, banned }: { status: SuperAdminTenantSummary['status']; banned: boolean }) {
const { t } = useTranslation()
if (banned) {

View File

@@ -0,0 +1,151 @@
import type { ModalComponent } from '@afilmory/ui'
import { DialogDescription, DialogHeader, DialogTitle } from '@afilmory/ui'
import { useTranslation } from 'react-i18next'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'
import { useSuperAdminTenantPhotosQuery } from '../hooks'
import type { SuperAdminTenantSummary } from '../types'
import { TenantUsageCell } from './TenantUsageCell'
interface TenantDetailModalProps {
tenant: SuperAdminTenantSummary
}
export const TenantDetailModal: ModalComponent<TenantDetailModalProps> = ({ tenant }) => {
const { t } = useTranslation()
const photoCount = tenant.usageTotals?.find((u) => u.eventType === 'photo.asset.created')?.totalQuantity ?? 0
return (
<div className="flex flex-col gap-4 h-full">
<DialogHeader>
<DialogTitle>{tenant.name}</DialogTitle>
<DialogDescription>{tenant.slug}</DialogDescription>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full flex-1 flex flex-col min-h-0">
<TabsList>
<TabsTrigger value="overview">{t('superadmin.tenants.modal.tab.overview')}</TabsTrigger>
<TabsTrigger value="photos">{t('superadmin.tenants.modal.tab.photos')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 px-2 mt-4 flex-1 overflow-y-auto">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">{t('superadmin.tenants.table.usage')}</h3>
<TenantUsageCell usageTotals={tenant.usageTotals} />
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
{t('superadmin.tenants.modal.overview.details')}
</h3>
<dl className="divide-y border-t border-b">
<div className="py-3 flex justify-between text-sm">
<dt className="text-muted-foreground">{t('superadmin.tenants.table.status')}</dt>
<dd className="font-medium">{tenant.status}</dd>
</div>
<div className="py-3 flex justify-between text-sm">
<dt className="text-muted-foreground">{t('superadmin.tenants.table.plan')}</dt>
<dd className="font-medium">{tenant.planId}</dd>
</div>
<div className="py-3 flex justify-between text-sm">
<dt className="text-muted-foreground">{t('superadmin.tenants.modal.overview.photos')}</dt>
<dd className="font-medium">{photoCount}</dd>
</div>
<div className="py-3 flex justify-between text-sm">
<dt className="text-muted-foreground">{t('superadmin.tenants.table.created')}</dt>
<dd className="font-medium">{new Date(tenant.createdAt).toLocaleDateString()}</dd>
</div>
<div className="py-3 flex justify-between text-sm">
<dt className="text-muted-foreground">{t('superadmin.tenants.table.ban')}</dt>
<dd className="font-medium">{tenant.banned ? 'Yes' : 'No'}</dd>
</div>
</dl>
</div>
</TabsContent>
<TabsContent value="photos" className="mt-4 flex-1 overflow-y-auto min-h-0">
<TenantPhotosTab tenantId={tenant.id} />
</TabsContent>
</Tabs>
</div>
)
}
TenantDetailModal.contentClassName = 'max-w-4xl h-[80vh]'
function TenantPhotosTab({ tenantId }: { tenantId: string }) {
const { t } = useTranslation()
const { data, isLoading, isError } = useSuperAdminTenantPhotosQuery(tenantId)
if (isLoading) {
return <p className="text-sm text-muted-foreground">{t('superadmin.tenants.modal.photos.loading')}</p>
}
if (isError) {
return <p className="text-sm text-red-500">{t('superadmin.tenants.modal.photos.error')}</p>
}
const photos = data?.photos ?? []
if (photos.length === 0) {
return <p className="text-sm text-muted-foreground">{t('superadmin.tenants.modal.photos.empty')}</p>
}
return (
<div className="border rounded-md overflow-hidden">
<table className="min-w-full divide-y divide-border/40 text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground w-16">
{t('superadmin.tenants.modal.photos.table.preview')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('superadmin.tenants.modal.photos.table.name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground w-24">
{t('superadmin.tenants.modal.photos.table.size')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground w-40">
{t('superadmin.tenants.modal.photos.table.created')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{photos.map((photo) => (
<tr key={photo.id}>
<td className="px-4 py-2">
<div className="w-10 h-10 bg-muted rounded overflow-hidden">
{/* We might not have a public URL if it's not synced or private, but let's try */}
{photo.publicUrl && (
<img src={photo.publicUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
)}
</div>
</td>
<td className="px-4 py-2 font-medium truncate max-w-[200px]" title={photo.storageKey}>
{photo.storageKey.split('/').pop()}
</td>
<td className="px-4 py-2 text-muted-foreground">{formatBytes(photo.size ?? 0)}</td>
<td className="px-4 py-2 text-muted-foreground">{new Date(photo.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
}

View File

@@ -0,0 +1,113 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { BILLING_USAGE_EVENT_CONFIG } from '~/modules/photos/constants'
import type { BillingUsageEventType } from '~/modules/photos/types'
import type { SuperAdminTenantSummary } from '../types'
export function TenantUsageCell({ usageTotals }: { usageTotals: SuperAdminTenantSummary['usageTotals'] }) {
const { t, i18n } = useTranslation()
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
const numberFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 1,
}),
[locale],
)
const entries = useMemo(() => {
const totalsMap = new Map<BillingUsageEventType, { total: number; unit: 'count' | 'byte' }>()
usageTotals?.forEach((entry) => {
totalsMap.set(entry.eventType as BillingUsageEventType, {
total: entry.totalQuantity ?? 0,
unit: entry.unit,
})
})
return (Object.keys(BILLING_USAGE_EVENT_CONFIG) as BillingUsageEventType[]).map((eventType) => {
const config = BILLING_USAGE_EVENT_CONFIG[eventType]
const usage = totalsMap.get(eventType)
return {
eventType,
label: t(config.labelKey),
tone: config.tone,
total: usage?.total ?? 0,
unit: usage?.unit ?? 'count',
}
})
}, [usageTotals, t])
const activeEntries = entries.filter((entry) => entry.total > 0)
if (activeEntries.length === 0) {
return <p className="text-text-tertiary text-xs">{t('superadmin.tenants.usage.empty')}</p>
}
return (
<div className="flex flex-wrap gap-1.5">
{activeEntries.map((entry) => (
<UsageBadge
key={`${entry.eventType}`}
label={entry.label}
tone={entry.tone}
value={entry.total}
unit={entry.unit}
formatter={numberFormatter}
/>
))}
</div>
)
}
type UsageBadgeProps = {
label: string
tone: (typeof BILLING_USAGE_EVENT_CONFIG)[BillingUsageEventType]['tone']
value: number
unit: 'count' | 'byte'
formatter: Intl.NumberFormat
}
function UsageBadge({ label, tone, value, unit, formatter }: UsageBadgeProps) {
const toneClass =
tone === 'accent'
? 'bg-emerald-500/10 text-emerald-200 border-emerald-500/30'
: tone === 'warning'
? 'bg-rose-500/10 text-rose-200 border-rose-500/30'
: 'bg-fill/30 text-text-secondary border-border/40'
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${toneClass}`}
>
<span className="uppercase tracking-wide text-[10px] text-text-tertiary/80">{label}</span>
<span className="text-xs font-semibold text-text">{formatUsageValue(value, unit, formatter)}</span>
</span>
)
}
function formatUsageValue(value: number, unit: 'count' | 'byte', formatter: Intl.NumberFormat): string {
if (!Number.isFinite(value)) {
return '0'
}
if (unit === 'byte') {
return formatBytes(value)
}
return formatter.format(value)
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
}

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchSuperAdminSettings,
fetchSuperAdminTenantPhotos,
fetchSuperAdminTenants,
updateSuperAdminSettings,
updateSuperAdminTenantBan,
@@ -10,6 +11,7 @@ import {
import type {
SuperAdminSettingsResponse,
SuperAdminTenantListResponse,
SuperAdminTenantPhotosResponse,
UpdateSuperAdminSettingsPayload,
UpdateTenantBanPayload,
UpdateTenantPlanPayload,
@@ -74,3 +76,11 @@ export function useUpdateTenantBanMutation() {
},
})
}
export function useSuperAdminTenantPhotosQuery(tenantId: string | undefined) {
return useQuery<SuperAdminTenantPhotosResponse>({
queryKey: [...SUPER_ADMIN_TENANTS_QUERY_KEY, tenantId, 'photos'],
queryFn: () => fetchSuperAdminTenantPhotos(tenantId!),
enabled: !!tenantId,
})
}

View File

@@ -1,6 +1,6 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import type { BillingUsageTotalsEntry, PhotoSyncLogLevel } from '../photos/types'
import type { BillingUsageTotalsEntry, PhotoAssetListItem, PhotoSyncLogLevel } from '../photos/types'
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
export type SuperAdminSettingField = string
@@ -109,3 +109,7 @@ export interface UpdateTenantBanPayload {
tenantId: string
banned: boolean
}
export interface SuperAdminTenantPhotosResponse {
photos: PhotoAssetListItem[]
}

View File

@@ -71,6 +71,13 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--destructive-foreground: var(--color-red-700);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-700);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
}
:root,
@@ -218,6 +225,19 @@ body {
--color-ring: var(--color-accent);
--color-foreground: var(--color-text);
--color-muted-foreground: var(--color-text-secondary);
--animate-skeleton: skeleton 2s -1s infinite linear;
--color-warning-foreground: var(--warning-foreground);
--color-warning: var(--warning);
--color-success-foreground: var(--success-foreground);
--color-success: var(--success);
--color-info-foreground: var(--info-foreground);
--color-info: var(--info);
--color-destructive-foreground: var(--destructive-foreground);
@keyframes skeleton {
to {
background-position: -200% 0;
}
}
}
@layer theme {
@@ -257,3 +277,13 @@ body {
corner-shape: squircle;
border-radius: 12px;
}
.dark {
--destructive-foreground: var(--color-red-400);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-400);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
}

View File

@@ -737,6 +737,17 @@
"superadmin.tenants.description": "Switch plans for specific tenants or suspend those that violate policies.",
"superadmin.tenants.empty": "There are no tenants to manage right now.",
"superadmin.tenants.error.loading": "Unable to load tenant data: {{reason}}",
"superadmin.tenants.modal.overview.details": "Details",
"superadmin.tenants.modal.overview.photos": "Total Photos",
"superadmin.tenants.modal.photos.empty": "No photos found for this tenant.",
"superadmin.tenants.modal.photos.error": "Failed to load photos.",
"superadmin.tenants.modal.photos.loading": "Loading photos...",
"superadmin.tenants.modal.photos.table.created": "Created",
"superadmin.tenants.modal.photos.table.name": "Name",
"superadmin.tenants.modal.photos.table.preview": "Preview",
"superadmin.tenants.modal.photos.table.size": "Size",
"superadmin.tenants.modal.tab.overview": "Overview",
"superadmin.tenants.modal.tab.photos": "Photos",
"superadmin.tenants.plan.placeholder": "Select a plan",
"superadmin.tenants.refresh.button": "Refresh list",
"superadmin.tenants.refresh.loading": "Refreshing…",

View File

@@ -419,7 +419,6 @@
"photos.sync.toasts.conflict-error-desc": "当前无法解决冲突,请稍后再试。",
"photos.sync.toasts.conflict-resolved": "冲突已解决",
"photos.sync.toasts.conflict-select": "请先选择至少一个冲突项。",
"photos.sync.toasts.conflict-storage": "已将存储版本写入数据库。",
"photos.tabs.library": "图库管理",
"photos.tabs.storage": "素材存储",
"photos.tabs.sync": "存储同步",
@@ -737,6 +736,10 @@
"superadmin.tenants.description": "为特定租户切换订阅计划或封禁违规租户。",
"superadmin.tenants.empty": "当前没有可管理的租户。",
"superadmin.tenants.error.loading": "无法加载租户数据:{{reason}}",
"superadmin.tenants.modal.overview.details": "详细信息",
"superadmin.tenants.modal.overview.photos": "照片总数",
"superadmin.tenants.modal.tab.overview": "概览",
"superadmin.tenants.modal.tab.photos": "照片",
"superadmin.tenants.plan.placeholder": "选择订阅计划",
"superadmin.tenants.refresh.button": "刷新列表",
"superadmin.tenants.refresh.loading": "正在刷新…",

133
pnpm-lock.yaml generated
View File

@@ -103,7 +103,7 @@ importers:
version: 9.39.1(jiti@2.6.1)
eslint-config-hyoban:
specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
fast-glob:
specifier: 3.3.3
version: 3.3.3
@@ -939,7 +939,7 @@ importers:
version: 3.929.0
'@creem_io/better-auth':
specifier: 0.0.8
version: 0.0.8(patch_hash=9eddcbca5430bb73593d5b26bcc1e85830a89b543ed577340323b869bdce0154)(better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(creem@0.4.0)(zod@4.1.12)
version: 0.0.8(patch_hash=9eddcbca5430bb73593d5b26bcc1e85830a89b543ed577340323b869bdce0154)(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)
'@hono/node-server':
specifier: ^1.19.6
version: 1.19.6(hono@4.10.5)
@@ -951,7 +951,7 @@ importers:
version: 1.5.4
better-auth:
specifier: 1.3.34
version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
busboy:
specifier: 1.6.0
version: 1.6.0
@@ -1028,9 +1028,12 @@ importers:
'@afilmory/utils':
specifier: workspace:*
version: link:../../../packages/utils
'@base-ui-components/react':
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@creem_io/better-auth':
specifier: 0.0.8
version: 0.0.8(patch_hash=9eddcbca5430bb73593d5b26bcc1e85830a89b543ed577340323b869bdce0154)(better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(creem@0.4.0)(zod@4.1.12)
version: 0.0.8(patch_hash=9eddcbca5430bb73593d5b26bcc1e85830a89b543ed577340323b869bdce0154)(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)
'@essentials/request-timeout':
specifier: 1.3.0
version: 1.3.0
@@ -1084,7 +1087,7 @@ importers:
version: 5.90.8(react@19.2.0)
better-auth:
specifier: 1.3.34
version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -1108,7 +1111,7 @@ importers:
version: 10.2.0
jotai:
specifier: 2.15.1
version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0)
version: 2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0)
lucide-react:
specifier: 0.553.0
version: 0.553.0(react@19.2.0)
@@ -1138,7 +1141,7 @@ importers:
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-scan:
specifier: 0.4.3
version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)
version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)
sonner:
specifier: 2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1199,7 +1202,7 @@ importers:
version: 9.39.1(jiti@2.6.1)
eslint-config-hyoban:
specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
lint-staged:
specifier: 16.2.6
version: 16.2.6
@@ -2360,6 +2363,27 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@base-ui-components/react@1.0.0-beta.6':
resolution: {integrity: sha512-jE07DkS7JcGKGH4/hNPTabyGfEzrfDhGPM0h9exGDWwRhEJXZK9xhJ4m3fr+yPf8gpddOCMMoKyV4xITJKrSOA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui-components/utils@0.2.0':
resolution: {integrity: sha512-uLLsLpYxPVq/QNsrfHwXuI+uiCr8+P/SLtTgrA4hL5b4UmWo3dnhwo38edfu+gCgfblrJveDj1D4RxEvDc7RXw==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@@ -10852,6 +10876,9 @@ packages:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -11377,6 +11404,9 @@ packages:
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
tailwind-api-utils@1.0.3:
resolution: {integrity: sha512-KpzUHkH1ug1sq4394SLJX38ZtpeTiqQ1RVyFTTSY2XuHsNSTWUkRo108KmyyrMWdDbQrLYkSHaNKj/a3bmA4sQ==}
peerDependencies:
@@ -13654,6 +13684,31 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@base-ui-components/react@1.0.0-beta.6(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@babel/runtime': 7.28.4
'@base-ui-components/utils': 0.2.0(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/utils': 0.2.10
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
reselect: 5.1.1
tabbable: 6.3.0
use-sync-external-store: 1.6.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.3
'@base-ui-components/utils@0.2.0(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@babel/runtime': 7.28.4
'@floating-ui/utils': 0.2.10
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.3
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/core@1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)':
@@ -13774,9 +13829,9 @@ snapshots:
conventional-commits-filter: 5.0.0
conventional-commits-parser: 6.2.1
'@creem_io/better-auth@0.0.8(patch_hash=9eddcbca5430bb73593d5b26bcc1e85830a89b543ed577340323b869bdce0154)(better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(creem@0.4.0)(zod@4.1.12)':
'@creem_io/better-auth@0.0.8(patch_hash=9eddcbca5430bb73593d5b26bcc1e85830a89b543ed577340323b869bdce0154)(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)':
dependencies:
better-auth: 1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
better-auth: 1.3.34(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
creem: 0.4.0
zod: 4.1.12
@@ -18373,7 +18428,7 @@ snapshots:
batch-cluster@15.0.1: {}
better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
better-auth@1.3.34(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
'@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
@@ -18390,7 +18445,7 @@ snapshots:
nanostores: 1.0.1
zod: 4.1.12
optionalDependencies:
next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
@@ -20949,6 +21004,13 @@ snapshots:
jose@6.1.0: {}
jotai@2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0):
optionalDependencies:
'@babel/core': 7.28.4
'@babel/template': 7.27.2
'@types/react': 19.2.3
react: 19.2.0
jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0):
optionalDependencies:
'@babel/core': 7.28.5
@@ -22017,6 +22079,31 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.1
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001752
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.1
'@next/swc-darwin-x64': 16.0.1
'@next/swc-linux-arm64-gnu': 16.0.1
'@next/swc-linux-arm64-musl': 16.0.1
'@next/swc-linux-x64-gnu': 16.0.1
'@next/swc-linux-x64-musl': 16.0.1
'@next/swc-win32-arm64-msvc': 16.0.1
'@next/swc-win32-x64-msvc': 16.0.1
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
optional: true
next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.1
@@ -23158,7 +23245,7 @@ snapshots:
optionalDependencies:
react-dom: 19.2.0(react@19.2.0)
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2):
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
@@ -23167,7 +23254,7 @@ snapshots:
'@clack/prompts': 0.8.2
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/signals': 1.3.2(preact@10.27.2)
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
'@rollup/pluginutils': 5.3.0(rollup@4.53.2)
'@types/node': 20.19.25
bippy: 0.3.27(@types/react@19.2.3)(react@19.2.0)
esbuild: 0.25.11
@@ -23180,7 +23267,7 @@ snapshots:
react-dom: 19.2.0(react@19.2.0)
tsx: 4.20.6
optionalDependencies:
next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
unplugin: 2.1.0
@@ -23189,7 +23276,7 @@ snapshots:
- rollup
- supports-color
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2):
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
@@ -23198,7 +23285,7 @@ snapshots:
'@clack/prompts': 0.8.2
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/signals': 1.3.2(preact@10.27.2)
'@rollup/pluginutils': 5.3.0(rollup@4.53.2)
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
'@types/node': 20.19.25
bippy: 0.3.27(@types/react@19.2.3)(react@19.2.0)
esbuild: 0.25.11
@@ -23515,6 +23602,8 @@ snapshots:
requireindex@1.2.0: {}
reselect@5.1.1: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -24082,6 +24171,14 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.0):
dependencies:
client-only: 0.0.1
react: 19.2.0
optionalDependencies:
'@babel/core': 7.28.4
optional: true
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0):
dependencies:
client-only: 0.0.1
@@ -24132,6 +24229,8 @@ snapshots:
tabbable@6.2.0: {}
tabbable@6.3.0: {}
tailwind-api-utils@1.0.3(tailwindcss@4.1.17):
dependencies:
enhanced-resolve: 5.18.3