update ui layout

This commit is contained in:
rustmailer
2026-05-10 03:19:25 +08:00
parent 213c6452a8
commit 2f8ba5ad40
41 changed files with 337 additions and 628 deletions
@@ -59,7 +59,11 @@ export function RunningStateCellAction({ row }: Props) {
})
}
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('accounts.viewDetails')}
</span>
</Button>
)
}
+2 -20
View File
@@ -13,7 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
import { Mail, Users, Inbox, Zap } from 'lucide-react';
import { Mail, Users, Inbox, Zap, Paperclip } from 'lucide-react';
import { formatBytes, formatNumber } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { get_dashboard_stats, INITIAL_DASHBOARD_STATS, TimeBucket } from '@/api/system/api';
@@ -30,24 +30,6 @@ interface DailyActivity {
timestamp_ms: number;
}
const GithubIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
);
function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] {
const dateFormatter = new Intl.DateTimeFormat(locale, {
month: 'short',
@@ -223,7 +205,7 @@ export default function MailArchiveDashboard() {
<Card className="md:col-span-2 lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.totalAttachments')}</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
<Paperclip className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl font-bold">{formatNumber(stats1.attachment_count)}</div>
+120
View File
@@ -0,0 +1,120 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTranslation } from 'react-i18next'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useCurrentUser } from '@/hooks/use-current-user'
import { PermissionsDialog } from './permissions-dialog'
import Logo from '@/assets/logo.svg'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function AccountAccessList() {
const { t } = useTranslation()
const { data: user } = useCurrentUser()
const { getEmailById } = useMinimalAccountList()
const [permissionsOpen, setPermissionsOpen] = useState(false)
const [permissionsAccountId, setPermissionsAccountId] = useState<number | undefined>(undefined)
if (!user) return null
const accessibleAccountIds = user.account_access_map instanceof Map
? Array.from(user.account_access_map.keys())
: Object.keys(user.account_access_map || {}).map(Number)
const roleSummary = user.account_roles_summary || {}
if (accessibleAccountIds.length === 0) {
return (
<div className="flex h-[450px] items-center justify-center rounded-md border border-dashed mt-4">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center px-4">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('settings.access.empty.title')}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{t('settings.access.empty.description')}
</p>
</div>
</div>
)
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{accessibleAccountIds.map((accountId) => {
const email = getEmailById(accountId)
const roleName = roleSummary[accountId]
if (!email) return null
return (
<Card key={accountId} className="group hover:bg-accent/40 transition-all h-fit">
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary text-sm font-bold shrink-0">
{email.charAt(0).toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<CardTitle className="text-sm font-semibold truncate">{email}</CardTitle>
<CardDescription className="text-[10px] font-mono">
{t('settings.profile.account.id', { id: accountId })}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex items-center justify-between pt-3 border-t">
{roleName ? (
<Badge variant="secondary" className="text-[11px]">
{roleName}
</Badge>
) : (
<span />
)}
<Button
type="button"
variant="outline"
size="sm"
className="text-xs h-7"
onClick={() => {
setPermissionsAccountId(accountId)
setPermissionsOpen(true)
}}
>
{t('settings.profile.button.permissions')}
</Button>
</CardContent>
</Card>
)
})}
</div>
<PermissionsDialog
currentRow={user}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
mode="account"
accountId={permissionsAccountId}
/>
</>
)
}
@@ -25,11 +25,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { getPermissions, User } from '@/api/users/api'
import { CheckCircle2, XCircle } from 'lucide-react'
import { CheckCircle, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTranslation } from 'react-i18next'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
interface Props {
currentRow?: User
@@ -95,6 +96,7 @@ export function PermissionsDialog({
}: Props) {
const { t } = useTranslation()
const { getEmailById } = useMinimalAccountList();
const ownedPermissions = React.useMemo<string[]>(() => {
if (!currentRow) return []
@@ -120,7 +122,7 @@ export function PermissionsDialog({
mode === 'global'
? getGlobalCategories(t)
: getAccountCategories(t)
const title =
mode === 'global'
? t('permission.dialog.global_title')
@@ -140,56 +142,51 @@ export function PermissionsDialog({
<Badge variant="outline" className="text-[10px]">
{mode === 'global'
? t('permission.scope.global')
: t('permission.scope.account', { id: accountId })}
: t('permission.scope.account', { id: getEmailById(accountId!) })}
</Badge>
</div>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
<div className="grid gap-6 px-1">
<div className="flex flex-col gap-6 px-1">
{categories.map((cat) => (
<div key={cat.title} className="flex flex-col">
<h3 className="text-[11px] font-bold text-slate-500 border-l-4 border-blue-500 pl-2 mb-4 uppercase tracking-widest">
<h3 className="text-[11px] font-bold text-muted-foreground border-l-2 border-primary pl-2 mb-3 uppercase tracking-widest">
{cat.title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{cat.keys.map((key) => {
const item = permissions.get(key)
if (!item) return null
const hasPermission =
ownedPermissions.includes(item.value)
const hasPermission = ownedPermissions.includes(item.value)
return (
<div
key={item.value}
className={cn(
'flex items-center gap-2.5 p-2 rounded-md transition-all text-xs border',
'flex items-center gap-2.5 p-2.5 rounded-md border text-xs transition-all',
hasPermission
? 'bg-green-50/40 border-green-100 text-green-800 shadow-sm'
: 'bg-slate-50/30 border-transparent text-slate-400 opacity-60',
? 'bg-primary/5 border-primary/20'
: 'bg-muted/30 border-border opacity-50',
)}
>
{hasPermission ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-600 shrink-0" />
<CheckCircle className="w-4 h-4 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-slate-300 shrink-0" />
<XCircle className="w-4 h-4 text-muted-foreground/40 shrink-0" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span
className={cn(
'font-semibold text-sm leading-none truncate',
hasPermission
? 'text-slate-900'
: 'text-slate-500',
)}
>
<span className={cn(
'font-medium text-xs leading-none truncate',
hasPermission ? 'text-foreground' : 'text-muted-foreground',
)}>
{item.label}
</span>
<span className="text-xs opacity-70 font-mono mt-1 truncate">
<span className="text-[10px] text-muted-foreground font-mono mt-1 truncate">
{item.value}
</span>
</div>
+6 -1
View File
@@ -20,7 +20,7 @@
import { Outlet } from '@tanstack/react-router'
import { Main } from '@/components/layout/main'
import SidebarNav from './components/sidebar-nav'
import { KeyRound, Palette, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { KeyRound, Palette, SettingsIcon, ShieldCheck, UserCog, Waypoints } from 'lucide-react'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useTranslation } from 'react-i18next'
@@ -36,6 +36,11 @@ export default function Settings() {
href: '/settings/profile',
icon: <UserCog size={18} />,
},
{
title: t('settings.sidebar.access'),
href: '/settings/access',
icon: <ShieldCheck size={18} />
},
{
title: t('settings.appearance.title'),
href: '/settings/appearance',
@@ -35,17 +35,13 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { toast } from '@/hooks/use-toast'
import { PasswordInput } from '@/components/password-input'
import { update_user, User } from '@/api/users/api'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { FileWithPreview } from '@/hooks/use-file-upload'
import AvatarUpload from './avatar-upload'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { PermissionsDialog } from './permissions-dialog'
import { PermissionsDialog } from '../access/permissions-dialog'
const profileSchema = (t: (key: string) => string) => z.object({
username: z
@@ -104,10 +100,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
const [avatarFile, setAvatarFile] = useState<FileWithPreview | null>(null)
const [permissionsOpen, setPermissionsOpen] = useState(false)
const [permissionsMode, setPermissionsMode] =
useState<'global' | 'account'>('global')
const [permissionsAccountId, setPermissionsAccountId] =
useState<number | undefined>(undefined)
const [permissionsAccountId, setPermissionsAccountId] = useState<number | undefined>(undefined)
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema(t)),
@@ -149,15 +142,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
},
})
const { getEmailById } = useMinimalAccountList()
const accessibleAccountIds = user.account_access_map instanceof Map
? Array.from(user.account_access_map.keys())
: Object.keys(user.account_access_map || {}).map(Number)
const hasAccess = accessibleAccountIds.length > 0
const roleNames = user.global_roles_names
const roleSummary = user.account_roles_summary || {}
return (
<>
@@ -188,7 +173,6 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
size="sm"
className="text-xs"
onClick={() => {
setPermissionsMode('global')
setPermissionsAccountId(undefined)
setPermissionsOpen(true)
}}
@@ -260,77 +244,6 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
)}
/>
</div>
{hasAccess && (
<Separator orientation="vertical" className="hidden lg:block" />
)}
{hasAccess && (
<div className="space-y-4">
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t('settings.profile.section.accounts', {
count: accessibleAccountIds.length,
})}
</h2>
<ScrollArea className="h-[calc(100vh-16rem)] pr-4">
<div className="grid grid-cols-1 gap-3">
{accessibleAccountIds.map((accountId) => {
const email = getEmailById(accountId)
const roleName = roleSummary[accountId]
if (!email) return null
return (
<div
key={accountId}
className="group flex items-center justify-between p-3 rounded-xl border bg-card hover:bg-accent/40 transition-all shadow-sm"
>
<div className="flex items-center min-w-0">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary text-sm font-bold mr-4 shrink-0">
{email.charAt(0).toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">
{email}
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{t('settings.profile.account.id', {
id: accountId,
})}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{roleName && (
<Badge
variant="outline"
className="text-[11px]"
>
{roleName}
</Badge>
)}
<Button
type="button"
variant="ghost"
className="text-[10px]"
onClick={() => {
setPermissionsMode('account')
setPermissionsAccountId(accountId)
setPermissionsOpen(true)
}}
>
{t('settings.profile.button.permissions')}
</Button>
</div>
</div>
)
})}
</div>
</ScrollArea>
</div>
)}
</div>
<div className="flex justify-start pt-4">
@@ -346,12 +259,11 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
</div>
</form>
</Form>
<PermissionsDialog
currentRow={user}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
mode={permissionsMode}
mode="global"
accountId={permissionsAccountId}
/>
</>
@@ -41,7 +41,11 @@ export function PermissionsCellAction({ row }: Props) {
setCurrentRow(row.original)
setOpen('permissions')
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('roles.details.view_permissions')}</span>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('roles.details.view_permissions')}
</span>
</Button>
)
}
@@ -24,7 +24,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { getPermissions, UserRole } from '@/api/users/api'
import { CheckCircle2, XCircle } from 'lucide-react'
import { CheckCircle, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -71,7 +71,7 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogContent className="max-w-7xl w-[40vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogHeader className="pb-4 border-b">
<div className="flex items-center gap-3">
<DialogTitle>
@@ -87,19 +87,15 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
: t('roles.details.desc_account')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
<div className={cn(
"grid gap-6 px-1",
roleType === 'Global' ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1"
)}>
<div className="flex flex-col gap-6 px-1">
{categories.map((cat) => (
<div key={cat.titleKey} className="flex flex-col">
<h3 className="text-[11px] font-bold text-slate-500 border-l-4 border-blue-500 pl-2 mb-4 uppercase tracking-widest">
<h3 className="text-[11px] font-bold text-muted-foreground border-l-2 border-primary pl-2 mb-3 uppercase tracking-widest">
{t(cat.titleKey)}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{cat.keys.map((key) => {
const item = getPermissions(t).find(p => p.value === key);
if (!item) return null;
@@ -110,26 +106,23 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
<div
key={item.value}
className={cn(
"flex items-center gap-2.5 p-2 rounded-md transition-all text-xs border",
"flex items-center gap-2.5 p-2.5 rounded-md border text-xs transition-all",
hasPermission
? "bg-green-50/40 border-green-100 text-green-800 shadow-sm"
: "bg-slate-50/30 border-transparent text-slate-400 opacity-60"
? "bg-primary/5 border-primary/20 text-foreground"
: "bg-muted/30 border-border text-muted-foreground opacity-50"
)}
>
{hasPermission ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-600 shrink-0" />
<CheckCircle className="w-4 h-4 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-slate-300 shrink-0" />
<XCircle className="w-4 h-4 text-muted-foreground/30 shrink-0" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span className={cn(
"font-semibold text-sm leading-none truncate",
hasPermission ? "text-slate-900" : "text-slate-500"
)}>
<span className="font-medium text-xs leading-none truncate">
{item.label}
</span>
<span className="text-xs opacity-70 font-mono mt-1 truncate">
<span className="text-[10px] text-muted-foreground font-mono mt-1 truncate">
{item.value}
</span>
</div>
@@ -141,7 +134,6 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
))}
</div>
</div>
<div className="flex justify-end pt-4 border-t mt-auto">
<Button
variant="ghost"
@@ -223,7 +223,7 @@ export function UserActionDialog({ currentRow, open, onOpenChange }: Props) {
<DialogHeader className="p-6 pb-0 shrink-0">
<div className="flex items-center gap-4 mb-4">
{isEdit && currentRow?.avatar ? (
<img src={`data:image/png;base64,${currentRow.avatar}`} className="h-12 w-12 rounded-full border shadow-sm" alt="" />
<img src={`data:image/png;base64,${currentRow.avatar}`} className="h-12 w-12 rounded-full border shadow-sm object-cover" alt="" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<UserIcon className="h-6 w-6" />