feat(ui): refine default frontend layouts

This commit is contained in:
CaIon
2026-04-29 11:40:05 +08:00
parent 438410708f
commit f982544825
28 changed files with 926 additions and 587 deletions
+6 -2
View File
@@ -40,11 +40,16 @@ export function checkIsActive(
item: NavItem, item: NavItem,
mainNav = false mainNav = false
): boolean { ): boolean {
const hrefWithoutQuery = href.split('?')[0]
if (item.activeUrls?.some((url) => urlToString(url) === hrefWithoutQuery)) {
return true
}
// For collapsible items (NavCollapsible), check sub-items first // For collapsible items (NavCollapsible), check sub-items first
if ('items' in item && item.items) { if ('items' in item && item.items) {
const collapsibleItem = item as NavCollapsible const collapsibleItem = item as NavCollapsible
const items = collapsibleItem.items const items = collapsibleItem.items
const hrefWithoutQuery = href.split('?')[0]
// Check if any sub-item matches // Check if any sub-item matches
if ( if (
@@ -76,7 +81,6 @@ export function checkIsActive(
// Exact match // Exact match
if (href === itemUrl) return true if (href === itemUrl) return true
const hrefWithoutQuery = href.split('?')[0]
const itemUrlWithoutQuery = itemUrl.split('?')[0] const itemUrlWithoutQuery = itemUrl.split('?')[0]
const itemUrlHasQuery = itemUrl.includes('?') const itemUrlHasQuery = itemUrl.includes('?')
+2
View File
@@ -18,6 +18,8 @@ type BaseNavItem = {
title: string title: string
badge?: string badge?: string
icon?: React.ElementType icon?: React.ElementType
activeUrls?: (LinkProps['to'] | (string & {}))[]
configUrls?: (LinkProps['to'] | (string & {}))[]
} }
/** /**
+37 -2
View File
@@ -1,7 +1,10 @@
import { useState, useCallback, lazy, Suspense } from 'react' import { useState, useCallback, useMemo, lazy, Suspense } from 'react'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import { import {
CardStaggerContainer, CardStaggerContainer,
@@ -18,6 +21,7 @@ import { DEFAULT_TIME_GRANULARITY } from './constants'
import { import {
type DashboardSectionId, type DashboardSectionId,
DASHBOARD_DEFAULT_SECTION, DASHBOARD_DEFAULT_SECTION,
DASHBOARD_SECTION_IDS,
} from './section-registry' } from './section-registry'
import { type DashboardFilters, type QuotaDataItem } from './types' import { type DashboardFilters, type QuotaDataItem } from './types'
@@ -97,7 +101,9 @@ const SECTION_META: Record<
export function Dashboard() { export function Dashboard() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const params = route.useParams() const params = route.useParams()
const userRole = useAuthStore((state) => state.auth.user?.role)
const activeSection = (params.section ?? const activeSection = (params.section ??
DASHBOARD_DEFAULT_SECTION) as DashboardSectionId DASHBOARD_DEFAULT_SECTION) as DashboardSectionId
@@ -122,6 +128,24 @@ export function Dashboard() {
) )
const meta = SECTION_META[activeSection] ?? SECTION_META.overview const meta = SECTION_META[activeSection] ?? SECTION_META.overview
const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN)
const visibleSections = useMemo(
() =>
DASHBOARD_SECTION_IDS.filter(
(section) => section !== 'overview' && (section !== 'users' || isAdmin)
),
[isAdmin]
)
const handleSectionChange = useCallback(
(section: string) => {
void navigate({
to: '/dashboard/$section',
params: { section: section as DashboardSectionId },
})
},
[navigate]
)
const showSectionTabs = activeSection !== 'overview' && visibleSections.length > 1
return ( return (
<SectionPageLayout> <SectionPageLayout>
@@ -139,6 +163,17 @@ export function Dashboard() {
)} )}
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'> <div className='space-y-4'>
{showSectionTabs && (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{activeSection === 'overview' && ( {activeSection === 'overview' && (
<> <>
<SummaryCards /> <SummaryCards />
+44 -6
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi, useNavigate } from '@tanstack/react-router'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import { listDeployments } from './api' import { listDeployments } from './api'
import { DeploymentAccessGuard } from './components/deployment-access-guard' import { DeploymentAccessGuard } from './components/deployment-access-guard'
@@ -18,12 +19,28 @@ import { deploymentsQueryKeys } from './lib'
import { import {
type ModelsSectionId, type ModelsSectionId,
MODELS_DEFAULT_SECTION, MODELS_DEFAULT_SECTION,
MODELS_SECTION_IDS,
} from './section-registry' } from './section-registry'
const route = getRouteApi('/_authenticated/models/$section') const route = getRouteApi('/_authenticated/models/$section')
const SECTION_META: Record<
ModelsSectionId,
{ titleKey: string; descriptionKey: string }
> = {
metadata: {
titleKey: 'Metadata',
descriptionKey: 'Manage model metadata and configuration',
},
deployments: {
titleKey: 'Deployments',
descriptionKey: 'Manage model deployments',
},
}
function ModelsContent() { function ModelsContent() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { tabCategory, setTabCategory } = useModels() const { tabCategory, setTabCategory } = useModels()
const params = route.useParams() const params = route.useParams()
@@ -75,16 +92,26 @@ function ModelsContent() {
} }
}, [activeSection, isIoNetEnabled, loadingPhase, queryClient]) }, [activeSection, isIoNetEnabled, loadingPhase, queryClient])
const handleSectionChange = useCallback(
(section: string) => {
void navigate({
to: '/models/$section',
params: { section: section as ModelsSectionId },
})
},
[navigate]
)
const meta = SECTION_META[activeSection] ?? SECTION_META.metadata
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout>
<SectionPageLayout.Title> <SectionPageLayout.Title>
{activeSection === 'metadata' ? t('Metadata') : t('Deployments')} {t(meta.titleKey)}
</SectionPageLayout.Title> </SectionPageLayout.Title>
<SectionPageLayout.Description> <SectionPageLayout.Description>
{activeSection === 'metadata' {t(meta.descriptionKey)}
? t('Manage model metadata and configuration')
: t('Manage model deployments')}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
{activeSection === 'metadata' ? ( {activeSection === 'metadata' ? (
@@ -97,6 +124,16 @@ function ModelsContent() {
)} )}
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'>
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
{MODELS_SECTION_IDS.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{activeSection === 'metadata' ? ( {activeSection === 'metadata' ? (
<ModelsTable /> <ModelsTable />
) : ( ) : (
@@ -112,6 +149,7 @@ function ModelsContent() {
<DeploymentsTable /> <DeploymentsTable />
</DeploymentAccessGuard> </DeploymentAccessGuard>
)} )}
</div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
+16 -10
View File
@@ -15,7 +15,13 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { usePasskeyManagement } from '@/features/auth/passkey' import { usePasskeyManagement } from '@/features/auth/passkey'
@@ -163,7 +169,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
if (pageLoading || loading) { if (pageLoading || loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader>
<Skeleton className='h-6 w-48' /> <Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' /> <Skeleton className='mt-2 h-4 w-64' />
@@ -185,18 +191,18 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
return ( return (
<> <>
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader>
<h3 className='text-xl font-semibold tracking-tight'> <CardTitle className='text-xl tracking-tight'>
{t('Passkey Login')} {t('Passkey Login')}
</h3> </CardTitle>
<p className='text-muted-foreground mt-2 text-sm'> <CardDescription>
{t('Use Passkey to sign in without entering your password.')} {t('Use Passkey to sign in without entering your password.')}
</p> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='space-y-6'>
<div className='flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'> <div className='flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-stretch 2xl:flex-row 2xl:items-center'>
<div className='flex items-start gap-3'> <div className='flex items-start gap-3'>
<div className='bg-muted rounded-md p-2'> <div className='bg-muted rounded-md p-2'>
<KeyRound className='h-5 w-5' /> <KeyRound className='h-5 w-5' />
@@ -239,7 +245,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
{!enabled ? ( {!enabled ? (
<Button <Button
className='w-full sm:w-auto' className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={handleRegister} onClick={handleRegister}
disabled={!supported || registering} disabled={!supported || registering}
> >
@@ -253,7 +259,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant='outline' variant='outline'
className='w-full sm:w-auto' className='w-full sm:w-auto xl:w-full 2xl:w-auto'
disabled={removing} disabled={removing}
> >
{t('Remove Passkey')} {t('Remove Passkey')}
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next'
import { formatCompactNumber, formatQuota } from '@/lib/format'
import { getRoleLabel } from '@/lib/roles' import { getRoleLabel } from '@/lib/roles'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -15,23 +17,37 @@ interface ProfileHeaderProps {
} }
export function ProfileHeader({ profile, loading }: ProfileHeaderProps) { export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
const { t } = useTranslation()
if (loading) { if (loading) {
return ( return (
<div className='space-y-6'> <div className='bg-card overflow-hidden rounded-2xl border'>
<div className='flex flex-col items-center gap-4 text-center lg:flex-row lg:items-center lg:gap-6 lg:text-left'> <div className='p-5 sm:p-6'>
<Skeleton className='h-20 w-20 rounded-full' /> <div className='flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex-1 space-y-3 lg:space-y-2'> <div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
<div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-center lg:justify-start'> <Skeleton className='h-20 w-20 rounded-2xl' />
<div className='space-y-3'>
<div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-start'>
<Skeleton className='h-8 w-48' /> <Skeleton className='h-8 w-48' />
<Skeleton className='h-5 w-16' /> <Skeleton className='h-5 w-16' />
</div> </div>
<div className='flex flex-col items-center gap-1 sm:flex-row sm:justify-center sm:gap-4 lg:justify-start'> <div className='flex flex-col items-center gap-1 sm:flex-row sm:justify-start sm:gap-4'>
<Skeleton className='h-4 w-24' /> <Skeleton className='h-4 w-24' />
<Skeleton className='h-4 w-40' /> <Skeleton className='h-4 w-40' />
<Skeleton className='h-4 w-20' /> <Skeleton className='h-4 w-20' />
</div> </div>
</div> </div>
</div> </div>
<div className='grid gap-3 sm:grid-cols-3 lg:w-[480px]'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='rounded-xl border p-4'>
<Skeleton className='mb-3 h-3 w-20' />
<Skeleton className='h-7 w-24' />
</div>
))}
</div>
</div>
</div>
</div> </div>
) )
} }
@@ -41,25 +57,45 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
const displayName = getDisplayName(profile) const displayName = getDisplayName(profile)
const initials = getUserInitials(profile) const initials = getUserInitials(profile)
const roleLabel = getRoleLabel(profile.role) const roleLabel = getRoleLabel(profile.role)
const stats = [
{
label: t('Current Balance'),
value: formatQuota(profile.quota),
},
{
label: t('Total Usage'),
value: formatQuota(profile.used_quota),
},
{
label: t('API Requests'),
value: formatCompactNumber(profile.request_count),
},
]
return ( return (
<div className='space-y-6'> <div className='bg-card relative overflow-hidden rounded-2xl border'>
<div className='flex flex-col items-center gap-4 text-center lg:flex-row lg:items-center lg:gap-6 lg:text-left'> <div className='relative p-5 sm:p-6'>
<Avatar className='h-20 w-20 text-xl'> <div className='flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between'>
<AvatarFallback className='bg-primary/10 text-primary'> <div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
<Avatar className='ring-background h-20 w-20 rounded-2xl text-xl ring-4'>
<AvatarFallback className='bg-primary/10 text-primary rounded-2xl'>
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className='flex-1 space-y-3 lg:space-y-2'> <div className='min-w-0 flex-1 space-y-3'>
<div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-center lg:justify-start'> <div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-start'>
<h1 className='text-3xl font-semibold tracking-tight'> <h1 className='text-2xl font-semibold tracking-tight sm:text-3xl'>
{displayName} {displayName}
</h1> </h1>
<StatusBadge label={roleLabel} variant='neutral' copyable={false} /> <StatusBadge
label={roleLabel}
variant='neutral'
copyable={false}
/>
</div> </div>
<div className='text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4 lg:justify-start'> <div className='text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:flex-wrap sm:justify-start sm:gap-4'>
<span>@{profile.username}</span> <span>@{profile.username}</span>
{profile.email && ( {profile.email && (
<> <>
@@ -76,6 +112,24 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</div> </div>
</div> </div>
</div> </div>
<div className='grid gap-3 sm:grid-cols-3 lg:w-[480px]'>
{stats.map((item) => (
<div
key={item.label}
className='bg-background/70 rounded-xl border p-4 backdrop-blur'
>
<p className='text-muted-foreground text-xs font-medium'>
{item.label}
</p>
<p className='mt-2 truncate text-xl font-semibold tracking-tight'>
{item.value}
</p>
</div>
))}
</div>
</div>
</div>
</div> </div>
) )
} }
@@ -1,7 +1,13 @@
import { Shield, Key, Trash2 } from 'lucide-react' import { Shield, Key, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDialogs } from '@/hooks/use-dialog' import { useDialogs } from '@/hooks/use-dialog'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import type { UserProfile } from '../types' import type { UserProfile } from '../types'
import { AccessTokenDialog } from './dialogs/access-token-dialog' import { AccessTokenDialog } from './dialogs/access-token-dialog'
@@ -28,12 +34,12 @@ export function ProfileSecurityCard({
if (loading) { if (loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className='space-y-3 pt-6'>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' /> <Skeleton key={i} className='h-16 w-full' />
))} ))}
@@ -70,18 +76,25 @@ export function ProfileSecurityCard({
return ( return (
<> <>
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<h3 className='text-xl font-semibold tracking-tight'> <div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<Shield className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Security')} {t('Security')}
</h3> </CardTitle>
<p className='text-muted-foreground mt-2 text-sm'> <CardDescription>
{t('Manage your security settings and account access')} {t('Manage your security settings and account access')}
</p> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className='pt-6'>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'> <div className='grid grid-cols-1 gap-3 md:grid-cols-3'>
{securityActions.map((item) => ( {securityActions.map((item) => (
<button <button
key={item.title} key={item.title}
@@ -1,7 +1,13 @@
import { useState } from 'react' import { useState } from 'react'
import { Link2, Settings } from 'lucide-react' import { Link2, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { UserProfile } from '../types' import type { UserProfile } from '../types'
@@ -28,12 +34,12 @@ export function ProfileSettingsCard({
if (loading) { if (loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4 pt-6'>
<Skeleton className='h-10 w-full' /> <Skeleton className='h-10 w-full' />
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-20 w-full' /> <Skeleton key={i} className='h-20 w-full' />
@@ -44,25 +50,38 @@ export function ProfileSettingsCard({
} }
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<h3 className='text-xl font-semibold tracking-tight'> <div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<Settings className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Settings')} {t('Settings')}
</h3> </CardTitle>
<p className='text-muted-foreground mt-2 text-sm'> <CardDescription>
{t('Configure your account preferences and integrations')} {t('Configure your account preferences and integrations')}
</p> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className='pt-6'>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid w-full grid-cols-2'> <TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
<TabsTrigger value='bindings' className='gap-2'> <TabsTrigger
value='bindings'
className='h-auto gap-2 rounded-lg px-3 py-2.5'
>
<Link2 className='h-4 w-4' /> <Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span> <span className='hidden sm:inline'>{t('Account Bindings')}</span>
<span className='sm:hidden'>{t('Bindings')}</span> <span className='sm:hidden'>{t('Bindings')}</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value='settings' className='gap-2'> <TabsTrigger
value='settings'
className='h-auto gap-2 rounded-lg px-3 py-2.5'
>
<Settings className='h-4 w-4' /> <Settings className='h-4 w-4' />
<span className='hidden sm:inline'> <span className='hidden sm:inline'>
{t('Settings & Preferences')} {t('Settings & Preferences')}
@@ -182,23 +182,32 @@ export function SidebarModulesCard() {
} }
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<CardTitle className='flex items-center gap-2'> <div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<LayoutDashboard className='h-4 w-4' /> <LayoutDashboard className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Sidebar Personal Settings')} {t('Sidebar Personal Settings')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t('Customize sidebar display content')} {t('Customize sidebar display content')}
</CardDescription> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='space-y-5 pt-6'>
{sectionDefs.map((section) => { {sectionDefs.map((section) => {
const sectionEnabled = config[section.key]?.enabled !== false const sectionEnabled = config[section.key]?.enabled !== false
return ( return (
<div key={section.key} className='space-y-3'> <div
<div className='bg-muted/50 flex items-center justify-between rounded-lg border p-3'> key={section.key}
<div> className='bg-background/60 rounded-xl border p-3'
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='text-sm font-medium'>{section.title}</p> <p className='text-sm font-medium'>{section.title}</p>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{section.description} {section.description}
@@ -209,11 +218,11 @@ export function SidebarModulesCard() {
onCheckedChange={(v) => toggleSection(section.key, v)} onCheckedChange={(v) => toggleSection(section.key, v)}
/> />
</div> </div>
<div className='grid grid-cols-2 gap-2 sm:grid-cols-3'> <div className='mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1'>
{section.modules.map((mod) => ( {section.modules.map((mod) => (
<div <div
key={mod.key} key={mod.key}
className={`flex items-center justify-between rounded-lg border p-3 transition-opacity ${ className={`flex min-h-16 items-center justify-between rounded-lg border p-3 transition-opacity ${
sectionEnabled ? '' : 'opacity-50' sectionEnabled ? '' : 'opacity-50'
}`} }`}
> >
@@ -239,7 +248,7 @@ export function SidebarModulesCard() {
) )
})} })}
<div className='flex justify-end gap-2 border-t pt-4'> <div className='flex flex-col-reverse gap-2 border-t pt-4 sm:flex-row sm:justify-end'>
<Button variant='outline' onClick={handleReset}> <Button variant='outline' onClick={handleReset}>
{t('Reset to Default')} {t('Reset to Default')}
</Button> </Button>
@@ -249,9 +249,9 @@ export function AccountBindingsTab({
{bindings.map((binding) => ( {bindings.map((binding) => (
<div <div
key={binding.id} key={binding.id}
className='flex items-center justify-between rounded-lg border p-3' className='flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'
> >
<div className='flex items-center gap-3'> <div className='flex min-w-0 items-center gap-3'>
<div className='bg-muted shrink-0 rounded-md p-2'> <div className='bg-muted shrink-0 rounded-md p-2'>
<binding.icon className='h-4 w-4' /> <binding.icon className='h-4 w-4' />
</div> </div>
@@ -274,7 +274,7 @@ export function AccountBindingsTab({
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
className='ml-2 h-7 shrink-0 px-2.5 text-xs' className='h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
onClick={binding.onBind} onClick={binding.onBind}
disabled={binding.isBound && binding.id !== 'email'} disabled={binding.isBound && binding.id !== 'email'}
> >
@@ -304,9 +304,9 @@ export function AccountBindingsTab({
return ( return (
<div <div
key={provider.id} key={provider.id}
className='flex items-center justify-between rounded-lg border p-3' className='flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'
> >
<div className='flex items-center gap-3'> <div className='flex min-w-0 items-center gap-3'>
<div className='bg-muted shrink-0 rounded-md p-2'> <div className='bg-muted shrink-0 rounded-md p-2'>
<Link2 className='h-4 w-4' /> <Link2 className='h-4 w-4' />
</div> </div>
@@ -332,7 +332,7 @@ export function AccountBindingsTab({
<Button <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
className='text-destructive hover:text-destructive ml-2 h-7 shrink-0 px-2.5 text-xs' className='text-destructive hover:text-destructive h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
onClick={() => setUnbindTarget(binding)} onClick={() => setUnbindTarget(binding)}
> >
<Unlink className='mr-1 h-3 w-3' /> <Unlink className='mr-1 h-3 w-3' />
@@ -342,7 +342,7 @@ export function AccountBindingsTab({
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
className='ml-2 h-7 shrink-0 px-2.5 text-xs' className='h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
onClick={() => handleBindCustomOAuth(provider)} onClick={() => handleBindCustomOAuth(provider)}
> >
{t('Bind')} {t('Bind')}
@@ -132,7 +132,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
className='sr-only' className='sr-only'
/> />
<Icon className='h-5 w-5' /> <Icon className='h-5 w-5' />
<span className='text-sm font-medium'>{method.label}</span> <span className='text-sm font-medium'>{t(method.label)}</span>
</Label> </Label>
) )
})} })}
@@ -297,7 +297,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
{/* Receive Upstream Model Update Notifications (admin only) */} {/* Receive Upstream Model Update Notifications (admin only) */}
{isAdmin && ( {isAdmin && (
<div className='flex items-center justify-between rounded-lg border p-4'> <div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<Label htmlFor='upstreamModelUpdateNotify'> <Label htmlFor='upstreamModelUpdateNotify'>
{t('Receive Upstream Model Update Notifications')} {t('Receive Upstream Model Update Notifications')}
@@ -310,6 +310,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div> </div>
<Switch <Switch
id='upstreamModelUpdateNotify' id='upstreamModelUpdateNotify'
className='shrink-0'
checked={settings.upstream_model_update_notify_enabled} checked={settings.upstream_model_update_notify_enabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateField('upstream_model_update_notify_enabled', checked) updateField('upstream_model_update_notify_enabled', checked)
@@ -319,7 +320,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)} )}
{/* Accept Unset Model Price */} {/* Accept Unset Model Price */}
<div className='flex items-center justify-between rounded-lg border p-4'> <div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<Label htmlFor='acceptUnsetPrice'> <Label htmlFor='acceptUnsetPrice'>
{t('Accept Unpriced Models')} {t('Accept Unpriced Models')}
@@ -330,6 +331,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div> </div>
<Switch <Switch
id='acceptUnsetPrice' id='acceptUnsetPrice'
className='shrink-0'
checked={settings.accept_unset_model_ratio_model} checked={settings.accept_unset_model_ratio_model}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateField('accept_unset_model_ratio_model', checked) updateField('accept_unset_model_ratio_model', checked)
@@ -338,7 +340,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div> </div>
{/* Record IP Log */} {/* Record IP Log */}
<div className='flex items-center justify-between rounded-lg border p-4'> <div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<Label htmlFor='recordIp'>{t('Record IP Address')}</Label> <Label htmlFor='recordIp'>{t('Record IP Address')}</Label>
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
@@ -347,6 +349,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div> </div>
<Switch <Switch
id='recordIp' id='recordIp'
className='shrink-0'
checked={settings.record_ip_log} checked={settings.record_ip_log}
onCheckedChange={(checked) => updateField('record_ip_log', checked)} onCheckedChange={(checked) => updateField('record_ip_log', checked)}
/> />
+19 -10
View File
@@ -2,7 +2,13 @@ import { Shield, AlertTriangle, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDialogs } from '@/hooks/use-dialog' import { useDialogs } from '@/hooks/use-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { useTwoFA } from '../hooks' import { useTwoFA } from '../hooks'
@@ -27,7 +33,7 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
if (pageLoading || loading) { if (pageLoading || loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader>
<Skeleton className='h-6 w-48' /> <Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' /> <Skeleton className='mt-2 h-4 w-64' />
@@ -41,20 +47,20 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
return ( return (
<> <>
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader>
<h3 className='text-xl font-semibold tracking-tight'> <CardTitle className='text-xl tracking-tight'>
{t('Two-Factor Authentication')} {t('Two-Factor Authentication')}
</h3> </CardTitle>
<p className='text-muted-foreground mt-2 text-sm'> <CardDescription>
{t('Add an extra layer of security to your account')} {t('Add an extra layer of security to your account')}
</p> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='space-y-6'> <div className='space-y-6'>
{/* Status Section */} {/* Status Section */}
<div className='flex items-start justify-between'> <div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
<div className='flex items-start gap-4'> <div className='flex items-start gap-4'>
<div className='bg-muted rounded-md p-2'> <div className='bg-muted rounded-md p-2'>
<Shield className='h-5 w-5' /> <Shield className='h-5 w-5' />
@@ -97,7 +103,10 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
</div> </div>
{!status.enabled && ( {!status.enabled && (
<Button onClick={() => dialogs.open('setup')}> <Button
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={() => dialogs.open('setup')}
>
{t('Enable')} {t('Enable')}
</Button> </Button>
)} )}
@@ -105,7 +114,7 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
{/* Actions Section - Only show when enabled */} {/* Actions Section - Only show when enabled */}
{status.enabled && ( {status.enabled && (
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row'> <div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
<Button <Button
variant='outline' variant='outline'
className='flex-1' className='flex-1'
+12 -12
View File
@@ -30,21 +30,24 @@ export function Profile() {
<> <>
<AppHeader /> <AppHeader />
<Main> <Main>
<div className='min-h-0 flex-1 overflow-auto px-4 py-6'> <div className='min-h-0 flex-1 overflow-auto px-4 py-4 sm:py-6'>
<CardStaggerContainer className='space-y-8'> <CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-5 sm:gap-6'>
<CardStaggerItem> <CardStaggerItem>
<ProfileHeader profile={profile} loading={loading} /> <ProfileHeader profile={profile} loading={loading} />
</CardStaggerItem> </CardStaggerItem>
<CardStaggerItem> <CardStaggerItem>
<div className='grid gap-6 lg:grid-cols-2 lg:items-start'> <div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
<div className='space-y-6'> <div className='space-y-5 sm:space-y-6'>
<ProfileSettingsCard
profile={profile}
loading={loading}
onProfileUpdate={refreshProfile}
/>
<ProfileSecurityCard profile={profile} loading={loading} /> <ProfileSecurityCard profile={profile} loading={loading} />
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div> </div>
<div className='space-y-6'> <div className='space-y-5 sm:space-y-6 xl:sticky xl:top-6'>
{checkinEnabled && ( {checkinEnabled && (
<CheckinCalendarCard <CheckinCalendarCard
checkinEnabled={checkinEnabled} checkinEnabled={checkinEnabled}
@@ -52,12 +55,9 @@ export function Profile() {
turnstileSiteKey={turnstileSiteKey} turnstileSiteKey={turnstileSiteKey}
/> />
)} )}
<ProfileSettingsCard
profile={profile}
loading={loading}
onProfileUpdate={refreshProfile}
/>
{canConfigureSidebar && <SidebarModulesCard />} {canConfigureSidebar && <SidebarModulesCard />}
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div> </div>
</div> </div>
</CardStaggerItem> </CardStaggerItem>
+79 -19
View File
@@ -1,6 +1,10 @@
import { getRouteApi } from '@tanstack/react-router' import { useCallback, useMemo } from 'react'
import { getRouteApi, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import type { NavGroup } from '@/components/layout/types'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog' import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
import { UserInfoDialog } from './components/dialogs/user-info-dialog' import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons' import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
@@ -16,9 +20,29 @@ import {
} from './section-registry' } from './section-registry'
const route = getRouteApi('/_authenticated/usage-logs/$section') const route = getRouteApi('/_authenticated/usage-logs/$section')
const TASK_LOG_SECTIONS = ['drawing', 'task'] as const
const SECTION_META: Record<
UsageLogsSectionId,
{ titleKey: string; descriptionKey: string }
> = {
common: {
titleKey: 'Common Logs',
descriptionKey: 'View and manage your API usage logs',
},
drawing: {
titleKey: 'Drawing Logs',
descriptionKey: 'View and manage your drawing logs',
},
task: {
titleKey: 'Task Logs',
descriptionKey: 'View and manage your task logs',
},
}
function UsageLogsContent() { function UsageLogsContent() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const params = route.useParams() const params = route.useParams()
const activeCategory: UsageLogsSectionId = const activeCategory: UsageLogsSectionId =
params.section && isUsageLogsSectionId(params.section) params.section && isUsageLogsSectionId(params.section)
@@ -32,31 +56,54 @@ function UsageLogsContent() {
affinityDialogOpen, affinityDialogOpen,
setAffinityDialogOpen, setAffinityDialogOpen,
} = useUsageLogsContext() } = useUsageLogsContext()
const tabNavGroups = useMemo<NavGroup[]>(
() => [
{
title: 'Task Logs',
items: TASK_LOG_SECTIONS.map((section) => ({
title: SECTION_META[section].titleKey,
url: `/usage-logs/${section}`,
})),
},
],
[]
)
const filteredTabGroups = useSidebarConfig(tabNavGroups)
const visibleSections = useMemo(
() =>
(filteredTabGroups[0]?.items ?? [])
.map((item) => {
if (!('url' in item) || typeof item.url !== 'string') return null
return item.url.split('/').pop() ?? null
})
.filter(
(section): section is UsageLogsSectionId =>
Boolean(section && isUsageLogsSectionId(section))
),
[filteredTabGroups]
)
const title = const handleSectionChange = useCallback(
activeCategory === 'common' (section: string) => {
? t('Common Logs') void navigate({
: activeCategory === 'drawing' to: '/usage-logs/$section',
? t('Drawing Logs') params: { section: section as UsageLogsSectionId },
: activeCategory === 'task' })
? t('Task Logs') },
: t('Usage Logs') [navigate]
)
const description = const pageMeta =
activeCategory === 'common' activeCategory === 'common' ? SECTION_META.common : SECTION_META.task
? t('View and manage your API usage logs') const showTaskSwitcher =
: activeCategory === 'drawing' activeCategory !== 'common' && visibleSections.length > 1
? t('View and manage your drawing logs')
: activeCategory === 'task'
? t('View and manage your task logs')
: t('View and manage your API usage logs')
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout>
<SectionPageLayout.Title>{title}</SectionPageLayout.Title> <SectionPageLayout.Title>{t(pageMeta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Description> <SectionPageLayout.Description>
{description} {t(pageMeta.descriptionKey)}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
{activeCategory !== 'common' && ( {activeCategory !== 'common' && (
@@ -64,7 +111,20 @@ function UsageLogsContent() {
)} )}
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'>
{showTaskSwitcher && (
<Tabs value={activeCategory} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<UsageLogsTable logCategory={activeCategory} /> <UsageLogsTable logCategory={activeCategory} />
</div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
@@ -1,7 +1,14 @@
import { Share2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -24,18 +31,18 @@ export function AffiliateRewardsCard({
const { t } = useTranslation() const { t } = useTranslation()
if (loading) { if (loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-8'> <CardContent className='space-y-6 pt-6'>
{/* Statistics Skeleton */} {/* Statistics Skeleton */}
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-6'> <div className='grid grid-cols-1 gap-3'>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='space-y-2'> <div key={i} className='rounded-lg border p-3'>
<Skeleton className='h-3 w-16' /> <Skeleton className='h-3 w-16' />
<Skeleton className='h-8 w-24' /> <Skeleton className='mt-2 h-8 w-24' />
</div> </div>
))} ))}
</div> </div>
@@ -59,41 +66,50 @@ export function AffiliateRewardsCard({
const hasRewards = (user?.aff_quota ?? 0) > 0 const hasRewards = (user?.aff_quota ?? 0) > 0
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<h3 className='text-xl font-semibold tracking-tight'> <div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<Share2 className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Referral Program')} {t('Referral Program')}
</h3> </CardTitle>
<p className='text-muted-foreground mt-2 text-sm'> <CardDescription>
{t('Share your link and earn rewards')} {t('Share your link and earn rewards')}
</p> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className='space-y-8'> <CardContent className='space-y-6 pt-6'>
{/* Statistics */} {/* Statistics */}
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-6'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-3 xl:grid-cols-1'>
<div className='space-y-2'> <div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'> <div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Pending')} {t('Pending')}
</div> </div>
<div className='text-2xl font-semibold'> <div className='mt-2 text-2xl font-semibold break-all'>
{formatQuota(user?.aff_quota ?? 0)} {formatQuota(user?.aff_quota ?? 0)}
</div> </div>
</div> </div>
<div className='space-y-2'> <div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'> <div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Total Earned')} {t('Total Earned')}
</div> </div>
<div className='text-2xl font-semibold'> <div className='mt-2 text-2xl font-semibold break-all'>
{formatQuota(user?.aff_history_quota ?? 0)} {formatQuota(user?.aff_history_quota ?? 0)}
</div> </div>
</div> </div>
<div className='space-y-2'> <div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'> <div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Invites')} {t('Invites')}
</div> </div>
<div className='text-2xl font-semibold'>{user?.aff_count ?? 0}</div> <div className='mt-2 text-2xl font-semibold'>
{user?.aff_count ?? 0}
</div>
</div> </div>
</div> </div>
@@ -1,11 +1,17 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Gift, ExternalLink, Loader2, Receipt } from 'lucide-react' import { Gift, ExternalLink, Loader2, Receipt, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatNumber } from '@/lib/format' import { formatNumber } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -119,12 +125,12 @@ export function RechargeFormCard({
if (loading) { if (loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-8'> <CardContent className='space-y-6 pt-6'>
<div className='space-y-6'> <div className='space-y-6'>
{/* Preset Amounts Skeleton */} {/* Preset Amounts Skeleton */}
<div className='space-y-3'> <div className='space-y-3'>
@@ -167,31 +173,36 @@ export function RechargeFormCard({
} }
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<div className='flex items-center justify-between'> <div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div> <div className='flex min-w-0 items-center gap-3'>
<h3 className='text-xl font-semibold tracking-tight'> <div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<WalletCards className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Add Funds')} {t('Add Funds')}
</h3> </CardTitle>
<p className='text-muted-foreground mt-2 text-sm'> <CardDescription>
{t('Choose an amount and payment method')} {t('Choose an amount and payment method')}
</p> </CardDescription>
</div>
</div> </div>
{onOpenBilling && ( {onOpenBilling && (
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
onClick={onOpenBilling} onClick={onOpenBilling}
className='gap-2' className='w-full gap-2 sm:w-auto'
> >
<Receipt className='h-4 w-4' /> <Receipt className='h-4 w-4' />
{t('Billing')} {t('Order History')}
</Button> </Button>
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className='space-y-8'> <CardContent className='space-y-6 pt-6'>
{/* Online Topup Section */} {/* Online Topup Section */}
{hasAnyTopup ? ( {hasAnyTopup ? (
<div className='space-y-6'> <div className='space-y-6'>
@@ -202,7 +213,7 @@ export function RechargeFormCard({
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Amount')} {t('Amount')}
</Label> </Label>
<div className='grid grid-cols-2 gap-3 sm:grid-cols-4'> <div className='grid grid-cols-2 gap-3 md:grid-cols-4'>
{presetAmounts.map((preset, index) => { {presetAmounts.map((preset, index) => {
const discount = const discount =
preset.discount || preset.discount ||
@@ -224,7 +235,7 @@ export function RechargeFormCard({
key={index} key={index}
variant='outline' variant='outline'
className={cn( className={cn(
'hover:border-foreground h-auto rounded-lg p-4 text-left whitespace-normal', 'hover:border-foreground flex h-auto flex-col items-start rounded-lg p-4 text-left whitespace-normal',
selectedPreset === preset.value selectedPreset === preset.value
? 'border-foreground bg-foreground/5' ? 'border-foreground bg-foreground/5'
: 'border-muted' : 'border-muted'
@@ -264,7 +275,7 @@ export function RechargeFormCard({
> >
{t('Custom Amount')} {t('Custom Amount')}
</Label> </Label>
<div className='relative'> <div className='grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center'>
<Input <Input
id='topup-amount' id='topup-amount'
type='number' type='number'
@@ -272,9 +283,9 @@ export function RechargeFormCard({
onChange={(e) => handleAmountChange(e.target.value)} onChange={(e) => handleAmountChange(e.target.value)}
min={minTopup} min={minTopup}
placeholder={`Minimum ${minTopup}`} placeholder={`Minimum ${minTopup}`}
className='pr-32 text-lg' className='text-lg'
/> />
<div className='absolute end-3 top-1/2 flex -translate-y-1/2 items-center gap-2'> <div className='bg-muted/30 flex min-h-10 items-center justify-between gap-3 rounded-md border px-3 lg:min-w-52'>
<span className='text-muted-foreground text-xs'> <span className='text-muted-foreground text-xs'>
{t('Amount to pay:')} {t('Amount to pay:')}
</span> </span>
@@ -294,7 +305,7 @@ export function RechargeFormCard({
{t('Payment Method')} {t('Payment Method')}
</Label> </Label>
{hasStandardPaymentMethods ? ( {hasStandardPaymentMethods ? (
<div className='flex flex-wrap gap-3'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'>
{topupInfo?.pay_methods?.map((method) => { {topupInfo?.pay_methods?.map((method) => {
const minTopup = method.min_topup || 0 const minTopup = method.min_topup || 0
const disabled = minTopup > topupAmount const disabled = minTopup > topupAmount
@@ -305,7 +316,7 @@ export function RechargeFormCard({
variant='outline' variant='outline'
onClick={() => onPaymentMethodSelect(method)} onClick={() => onPaymentMethodSelect(method)}
disabled={disabled || !!paymentLoading} disabled={disabled || !!paymentLoading}
className='gap-2 rounded-lg' className='justify-start gap-2 rounded-lg'
> >
{paymentLoading === method.type ? ( {paymentLoading === method.type ? (
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className='h-4 w-4 animate-spin' />
@@ -355,7 +366,7 @@ export function RechargeFormCard({
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Waffo Payment')} {t('Waffo Payment')}
</Label> </Label>
<div className='flex flex-wrap gap-3'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'>
{waffoPayMethods?.map((method, index) => { {waffoPayMethods?.map((method, index) => {
const loadingKey = `waffo-${index}` const loadingKey = `waffo-${index}`
const waffoMin = waffoMinTopup || 0 const waffoMin = waffoMinTopup || 0
@@ -367,7 +378,7 @@ export function RechargeFormCard({
variant='outline' variant='outline'
onClick={() => onWaffoMethodSelect(method, index)} onClick={() => onWaffoMethodSelect(method, index)}
disabled={belowMin || !!paymentLoading} disabled={belowMin || !!paymentLoading}
className='gap-2 rounded-lg' className='justify-start gap-2 rounded-lg'
> >
{paymentLoading === loadingKey ? ( {paymentLoading === loadingKey ? (
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className='h-4 w-4 animate-spin' />
@@ -434,7 +445,7 @@ export function RechargeFormCard({
)} )}
{/* Redemption Code Section */} {/* Redemption Code Section */}
<div className='space-y-3 border-t pt-8'> <div className='space-y-3 border-t pt-6'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Gift className='text-muted-foreground h-4 w-4' /> <Gift className='text-muted-foreground h-4 w-4' />
<Label <Label
@@ -444,7 +455,7 @@ export function RechargeFormCard({
{t('Have a Code?')} {t('Have a Code?')}
</Label> </Label>
</div> </div>
<div className='flex gap-2'> <div className='flex flex-col gap-2 sm:flex-row'>
<Input <Input
id='redemption-code' id='redemption-code'
value={redemptionCode} value={redemptionCode}
@@ -452,7 +463,12 @@ export function RechargeFormCard({
placeholder={t('Enter your redemption code')} placeholder={t('Enter your redemption code')}
className='flex-1' className='flex-1'
/> />
<Button onClick={onRedeem} disabled={redeeming} variant='outline'> <Button
onClick={onRedeem}
disabled={redeeming}
variant='outline'
className='sm:w-auto'
>
{redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} {redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Redeem')} {t('Redeem')}
</Button> </Button>
@@ -6,7 +6,13 @@ import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { import {
Select, Select,
@@ -185,11 +191,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
if (loading) { if (loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4 pt-6'>
<Skeleton className='h-20 w-full' /> <Skeleton className='h-20 w-full' />
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
@@ -207,17 +213,25 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
return ( return (
<> <>
<Card> <Card className='overflow-hidden'>
<CardHeader> <CardHeader className='border-b'>
<CardTitle className='flex items-center gap-2 text-base'> <div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<Crown className='h-4 w-4' /> <Crown className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Subscription Plans')} {t('Subscription Plans')}
</CardTitle> </CardTitle>
<CardDescription>
{t('Purchase a plan to enjoy model benefits')}
</CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className='space-y-5'> <CardContent className='space-y-5 pt-6'>
{/* My subscriptions & billing preference */} {/* My subscriptions & billing preference */}
<Card className='bg-muted/50'> <div className='rounded-xl border p-4'>
<CardContent className='p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'> <div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm font-medium'> <span className='text-sm font-medium'>
@@ -316,9 +330,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
<div className='max-h-64 space-y-3 overflow-y-auto pr-1'> <div className='max-h-64 space-y-3 overflow-y-auto pr-1'>
{allSubscriptions.map((sub) => { {allSubscriptions.map((sub) => {
const subscription = sub.subscription const subscription = sub.subscription
const totalAmount = Number( const totalAmount = Number(subscription?.amount_total || 0)
subscription?.amount_total || 0
)
const usedAmount = Number(subscription?.amount_used || 0) const usedAmount = Number(subscription?.amount_used || 0)
const remainAmount = const remainAmount =
totalAmount > 0 totalAmount > 0
@@ -400,8 +412,8 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className='cursor-help'> <span className='cursor-help'>
{formatQuota(usedAmount)}/ {formatQuota(usedAmount)}/
{formatQuota(totalAmount)} ·{' '} {formatQuota(totalAmount)} · {t('Remaining')}{' '}
{t('Remaining')} {formatQuota(remainAmount)} {formatQuota(remainAmount)}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -436,8 +448,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
{t('Purchase a plan to enjoy model benefits')} {t('Purchase a plan to enjoy model benefits')}
</p> </p>
)} )}
</CardContent> </div>
</Card>
{/* Available plans grid */} {/* Available plans grid */}
{plans.length > 0 ? ( {plans.length > 0 ? (
@@ -469,20 +480,14 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
return ( return (
<Card <Card
key={plan.id} key={plan.id}
className={`relative transition-shadow hover:shadow-md ${ className={cn(
isPopular ? 'ring-primary ring-2' : '' 'transition-shadow hover:shadow-md',
}`} isPopular && 'border-primary/70 shadow-sm'
>
{isPopular && (
<div className='absolute -top-2.5 left-3'>
<StatusBadge variant='info' copyable={false}>
<Sparkles className='h-3 w-3' />
{t('Recommended')}
</StatusBadge>
</div>
)} )}
<CardContent className='flex h-full flex-col p-4 pt-5'> >
<div className='mb-2'> <CardContent className='flex h-full flex-col p-4'>
<div className='mb-2 flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h4 className='truncate font-semibold'> <h4 className='truncate font-semibold'>
{plan.title || t('Subscription Plans')} {plan.title || t('Subscription Plans')}
</h4> </h4>
@@ -492,6 +497,17 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
</p> </p>
)} )}
</div> </div>
{isPopular && (
<StatusBadge
variant='info'
copyable={false}
className='shrink-0'
>
<Sparkles className='h-3 w-3' />
{t('Recommended')}
</StatusBadge>
)}
</div>
<div className='py-2'> <div className='py-2'>
<span className='text-primary text-2xl font-bold'> <span className='text-primary text-2xl font-bold'>
@@ -1,5 +1,7 @@
import { Activity, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import type { UserWalletData } from '../types' import type { UserWalletData } from '../types'
@@ -13,13 +15,21 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
if (props.loading) { if (props.loading) {
return ( return (
<Card> <Card className='overflow-hidden'>
<CardContent> <CardContent className='p-0'>
<div className='grid grid-cols-1 gap-6 sm:grid-cols-3 sm:gap-8'> <div className='grid grid-cols-1 sm:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='space-y-2'> <div
<Skeleton className='h-5 w-28' /> key={i}
<Skeleton className='h-11 w-32' /> className={cn(
'flex items-center justify-center px-4 py-3 sm:px-5 sm:py-4',
i > 0 && 'border-t sm:border-t-0 sm:border-l'
)}
>
<div className='w-full max-w-44'>
<Skeleton className='h-4 w-24' />
<Skeleton className='mt-2 h-7 w-32' />
</div>
</div> </div>
))} ))}
</div> </div>
@@ -28,39 +38,47 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
) )
} }
const stats = [
{
label: t('Current Balance'),
value: formatQuota(props.user?.quota ?? 0),
icon: WalletCards,
},
{
label: t('Total Usage'),
value: formatQuota(props.user?.used_quota ?? 0),
icon: BarChart3,
},
{
label: t('API Requests'),
value: (props.user?.request_count ?? 0).toLocaleString(),
icon: Activity,
},
]
return ( return (
<Card> <Card className='overflow-hidden'>
<CardContent> <CardContent className='p-0'>
<div className='grid grid-cols-1 gap-6 sm:grid-cols-3 sm:gap-8'> <div className='grid grid-cols-1 sm:grid-cols-3'>
{/* Current Balance */} {stats.map((item, index) => (
<div className='min-w-0 space-y-2'> <div
<div className='text-muted-foreground text-sm font-medium'> key={item.label}
{t('Current Balance')} className={cn(
'flex min-w-0 justify-center px-4 py-3 sm:px-5 sm:py-4',
index > 0 && 'border-t sm:border-t-0 sm:border-l'
)}
>
<div className='min-w-0 text-center'>
<div className='text-muted-foreground flex items-center justify-center gap-1.5 text-xs font-medium'>
<item.icon className='h-3.5 w-3.5' />
{item.label}
</div> </div>
<div className='text-3xl leading-tight font-semibold tracking-tight break-all lg:text-4xl'> <div className='mt-1 text-xl leading-tight font-semibold tracking-tight break-all lg:text-2xl'>
{formatQuota(props.user?.quota ?? 0)} {item.value}
</div> </div>
</div> </div>
{/* Total Usage */}
<div className='min-w-0 space-y-2'>
<div className='text-muted-foreground text-sm font-medium'>
{t('Total Usage')}
</div>
<div className='text-3xl leading-tight font-semibold tracking-tight break-all lg:text-4xl'>
{formatQuota(props.user?.used_quota ?? 0)}
</div>
</div>
{/* Request Count */}
<div className='min-w-0 space-y-2'>
<div className='text-muted-foreground text-sm font-medium'>
{t('API Requests')}
</div>
<div className='text-3xl leading-tight font-semibold tracking-tight break-all lg:text-4xl'>
{(props.user?.request_count ?? 0).toLocaleString()}
</div>
</div> </div>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+11 -9
View File
@@ -239,10 +239,13 @@ export function Wallet(props: WalletProps) {
{t('Manage your balance and payment methods')} {t('Manage your balance and payment methods')}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='grid gap-6 lg:grid-cols-3'> <div className='mx-auto flex w-full max-w-7xl flex-col gap-4'>
{/* Left Column - Stats & Recharge */}
<div className='space-y-6 lg:col-span-2'>
<WalletStatsCard user={user} loading={userLoading} /> <WalletStatsCard user={user} loading={userLoading} />
<SubscriptionPlansCard topupInfo={topupInfo} />
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.4fr)] xl:items-start'>
<div className='min-w-0'>
<RechargeFormCard <RechargeFormCard
topupInfo={topupInfo} topupInfo={topupInfo}
presetAmounts={presetAmounts} presetAmounts={presetAmounts}
@@ -270,12 +273,13 @@ export function Wallet(props: WalletProps) {
waffoPayMethods={topupInfo?.waffo_pay_methods} waffoPayMethods={topupInfo?.waffo_pay_methods}
waffoMinTopup={topupInfo?.waffo_min_topup} waffoMinTopup={topupInfo?.waffo_min_topup}
onWaffoMethodSelect={handleWaffoMethodSelect} onWaffoMethodSelect={handleWaffoMethodSelect}
enableWaffoPancakeTopup={topupInfo?.enable_waffo_pancake_topup} enableWaffoPancakeTopup={
topupInfo?.enable_waffo_pancake_topup
}
/> />
</div> </div>
{/* Right Column - Affiliate & Subscriptions */} <div className='xl:sticky xl:top-6'>
<div className='space-y-6 lg:col-span-1'>
<AffiliateRewardsCard <AffiliateRewardsCard
user={user} user={user}
affiliateLink={affiliateLink} affiliateLink={affiliateLink}
@@ -284,9 +288,7 @@ export function Wallet(props: WalletProps) {
/> />
</div> </div>
</div> </div>
</div>
{/* Subscription Plans */}
<SubscriptionPlansCard topupInfo={topupInfo} />
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
+6 -1
View File
@@ -55,7 +55,9 @@ const URL_TO_CONFIG_MAP: Record<string, { section: string; module: string }> = {
'/dashboard': { section: 'console', module: 'detail' }, '/dashboard': { section: 'console', module: 'detail' },
'/dashboard/overview': { section: 'console', module: 'detail' }, '/dashboard/overview': { section: 'console', module: 'detail' },
'/dashboard/models': { section: 'console', module: 'detail' }, '/dashboard/models': { section: 'console', module: 'detail' },
'/dashboard/users': { section: 'console', module: 'detail' },
'/keys': { section: 'console', module: 'token' }, '/keys': { section: 'console', module: 'token' },
'/usage-logs': { section: 'console', module: 'log' },
'/usage-logs/common': { section: 'console', module: 'log' }, '/usage-logs/common': { section: 'console', module: 'log' },
'/usage-logs/drawing': { section: 'console', module: 'midjourney' }, '/usage-logs/drawing': { section: 'console', module: 'midjourney' },
'/usage-logs/task': { section: 'console', module: 'task' }, '/usage-logs/task': { section: 'console', module: 'task' },
@@ -173,7 +175,10 @@ function isNavItemVisible(
// Handle direct link type // Handle direct link type
if ('url' in item && item.url) { if ('url' in item && item.url) {
return isModuleEnabled(item.url as string, adminConfig, userConfig) const configUrls = item.configUrls ?? [item.url]
return configUrls.some((url) =>
isModuleEnabled(url as string, adminConfig, userConfig)
)
} }
// Handle collapsible type (with sub-items) // Handle collapsible type (with sub-items)
+17 -9
View File
@@ -1,5 +1,6 @@
import { import {
LayoutDashboard, LayoutDashboard,
Activity,
Key, Key,
FileText, FileText,
Wallet, Wallet,
@@ -12,19 +13,14 @@ import {
FlaskConical, FlaskConical,
MessageSquare, MessageSquare,
CreditCard, CreditCard,
ListTodo,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { WORKSPACE_IDS } from '@/components/layout/lib/workspace-registry' import { WORKSPACE_IDS } from '@/components/layout/lib/workspace-registry'
import { type SidebarData } from '@/components/layout/types' import { type SidebarData } from '@/components/layout/types'
import { getDashboardSectionNavItems } from '@/features/dashboard/section-registry'
import { getModelsSectionNavItems } from '@/features/models/section-registry'
import { getUsageLogsSectionNavItems } from '@/features/usage-logs/section-registry'
export function useSidebarData(): SidebarData { export function useSidebarData(): SidebarData {
const { t } = useTranslation() const { t } = useTranslation()
const user = useAuthStore((s) => s.auth.user)
const isAdmin = Boolean(user?.role && user.role >= 10)
return { return {
workspaces: [ workspaces: [
@@ -56,10 +52,15 @@ export function useSidebarData(): SidebarData {
id: 'general', id: 'general',
title: t('General'), title: t('General'),
items: [ items: [
{
title: t('Overview'),
url: '/dashboard/overview',
icon: Activity,
},
{ {
title: t('Dashboard'), title: t('Dashboard'),
url: '/dashboard/models',
icon: LayoutDashboard, icon: LayoutDashboard,
items: getDashboardSectionNavItems(t, { isAdmin }),
}, },
{ {
title: t('API Keys'), title: t('API Keys'),
@@ -68,8 +69,15 @@ export function useSidebarData(): SidebarData {
}, },
{ {
title: t('Usage Logs'), title: t('Usage Logs'),
url: '/usage-logs/common',
icon: FileText, icon: FileText,
items: getUsageLogsSectionNavItems(t), },
{
title: t('Task Logs'),
url: '/usage-logs/task',
activeUrls: ['/usage-logs/drawing'],
configUrls: ['/usage-logs/drawing', '/usage-logs/task'],
icon: ListTodo,
}, },
{ {
title: t('Wallet'), title: t('Wallet'),
@@ -94,8 +102,8 @@ export function useSidebarData(): SidebarData {
}, },
{ {
title: t('Models'), title: t('Models'),
url: '/models/metadata',
icon: Box, icon: Box,
items: getModelsSectionNavItems(t),
}, },
{ {
title: t('Users'), title: t('Users'),
+1
View File
@@ -2301,6 +2301,7 @@
"Or continue with": "Or continue with", "Or continue with": "Or continue with",
"Or enter this key manually:": "Or enter this key manually:", "Or enter this key manually:": "Or enter this key manually:",
"Order completed successfully": "Order completed successfully", "Order completed successfully": "Order completed successfully",
"Order History": "Order History",
"Order Payment Method": "Order Payment Method", "Order Payment Method": "Order Payment Method",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "Original Model", "Original Model": "Original Model",
+1
View File
@@ -2301,6 +2301,7 @@
"Or continue with": "Ou continuer avec", "Or continue with": "Ou continuer avec",
"Or enter this key manually:": "Ou entrez cette clé manuellement :", "Or enter this key manually:": "Ou entrez cette clé manuellement :",
"Order completed successfully": "Commande terminée avec succès", "Order completed successfully": "Commande terminée avec succès",
"Order History": "Historique des commandes",
"Order Payment Method": "Moyen de paiement (commande)", "Order Payment Method": "Moyen de paiement (commande)",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "Modèle Original", "Original Model": "Modèle Original",
+1
View File
@@ -2301,6 +2301,7 @@
"Or continue with": "または、以下で続行", "Or continue with": "または、以下で続行",
"Or enter this key manually:": "または、このキーを手動で入力してください:", "Or enter this key manually:": "または、このキーを手動で入力してください:",
"Order completed successfully": "注文が正常に完了しました", "Order completed successfully": "注文が正常に完了しました",
"Order History": "注文履歴",
"Order Payment Method": "注文の支払い方法", "Order Payment Method": "注文の支払い方法",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "オリジナルモデル", "Original Model": "オリジナルモデル",
+1
View File
@@ -2301,6 +2301,7 @@
"Or continue with": "Или продолжить с", "Or continue with": "Или продолжить с",
"Or enter this key manually:": "Или введите этот ключ вручную:", "Or enter this key manually:": "Или введите этот ключ вручную:",
"Order completed successfully": "Заказ успешно завершен", "Order completed successfully": "Заказ успешно завершен",
"Order History": "История заказов",
"Order Payment Method": "Способ оплаты (заказа)", "Order Payment Method": "Способ оплаты (заказа)",
"org-...": "орг-...", "org-...": "орг-...",
"Original Model": "Оригинальная модель", "Original Model": "Оригинальная модель",
+1
View File
@@ -2301,6 +2301,7 @@
"Or continue with": "Hoặc tiếp tục với", "Or continue with": "Hoặc tiếp tục với",
"Or enter this key manually:": "Hoặc nhập khóa này thủ công:", "Or enter this key manually:": "Hoặc nhập khóa này thủ công:",
"Order completed successfully": "Đơn hàng đã hoàn thành thành công", "Order completed successfully": "Đơn hàng đã hoàn thành thành công",
"Order History": "Lịch sử đơn hàng",
"Order Payment Method": "Phương thức thanh toán đơn hàng", "Order Payment Method": "Phương thức thanh toán đơn hàng",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "Nguyên mẫu", "Original Model": "Nguyên mẫu",
+1
View File
@@ -2301,6 +2301,7 @@
"Or continue with": "或继续使用", "Or continue with": "或继续使用",
"Or enter this key manually:": "或手动输入此密钥:", "Or enter this key manually:": "或手动输入此密钥:",
"Order completed successfully": "订单已成功完成", "Order completed successfully": "订单已成功完成",
"Order History": "订单历史",
"Order Payment Method": "订单支付方式", "Order Payment Method": "订单支付方式",
"org-...": "org-...", "org-...": "org-...",
"Original Model": "原始模型", "Original Model": "原始模型",
+27 -27
View File
@@ -38,39 +38,39 @@
} }
.dark { .dark {
--background: oklch(0.165 0.012 258); --background: oklch(0.245 0.018 265);
--foreground: oklch(0.92 0.008 247.858); --foreground: oklch(0.88 0.014 252);
--card: oklch(0.205 0.012 258); --card: oklch(0.275 0.017 265);
--card-foreground: oklch(0.92 0.008 247.858); --card-foreground: oklch(0.88 0.014 252);
--popover: oklch(0.225 0.014 258); --popover: oklch(0.3 0.018 265);
--popover-foreground: oklch(0.92 0.008 247.858); --popover-foreground: oklch(0.88 0.014 252);
--primary: oklch(0.87 0.018 255.508); --primary: oklch(0.68 0.12 236);
--primary-foreground: oklch(0.235 0.042 265.755); --primary-foreground: oklch(0.985 0.004 247.858);
--secondary: oklch(0.255 0.012 258); --secondary: oklch(0.32 0.016 265);
--secondary-foreground: oklch(0.92 0.008 247.858); --secondary-foreground: oklch(0.88 0.014 252);
--muted: oklch(0.245 0.012 258); --muted: oklch(0.305 0.016 265);
--muted-foreground: oklch(0.68 0.014 257.417); --muted-foreground: oklch(0.72 0.018 252);
--accent: oklch(0.265 0.014 258); --accent: oklch(0.34 0.024 255);
--accent-foreground: oklch(0.92 0.008 247.858); --accent-foreground: oklch(0.9 0.012 252);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.31 0.014 258); --border: oklch(0.38 0.018 265);
--input: oklch(0.34 0.014 258); --input: oklch(0.405 0.018 265);
--ring: oklch(0.58 0.025 256.788); --ring: oklch(0.62 0.09 236);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.185 0.012 258); --sidebar: oklch(0.255 0.017 265);
--sidebar-foreground: oklch(0.92 0.008 247.858); --sidebar-foreground: oklch(0.86 0.014 252);
--sidebar-primary: oklch(0.75 0.14 233); --sidebar-primary: var(--primary);
--sidebar-primary-foreground: oklch(0.29 0.06 243); --sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.255 0.012 258); --sidebar-accent: oklch(0.325 0.018 265);
--sidebar-accent-foreground: oklch(0.92 0.008 247.858); --sidebar-accent-foreground: oklch(0.9 0.012 252);
--sidebar-border: oklch(0.3 0.014 258); --sidebar-border: oklch(0.37 0.018 265);
--sidebar-ring: oklch(0.52 0.02 256.788); --sidebar-ring: var(--ring);
--skeleton-base: oklch(0.245 0.01 258); --skeleton-base: oklch(0.31 0.014 265);
--skeleton-highlight: oklch(0.32 0.014 258); --skeleton-highlight: oklch(0.39 0.018 265);
} }
@theme inline { @theme inline {