feat(ui): unify page layout across Files/Shares/Trash/Teams/Settings

Introduce shared PageHeader + GlobalSearchBar and a canvas background
token so every authenticated page follows the same shape: top bar
(search + bell) → compressed PageHeader (breadcrumb + actions) →
Card-wrapped content. Files/Shares/Trash/Teams/Settings refactored to
use shadcn Card instead of hand-written border/bg divs. Files table
column widths fixed (Name flex via meta.flex, Size 96, Modified 160)
and Trash first column aligned to match. Trash English title renamed
to "Trash".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saltbo
2026-04-20 21:31:31 -04:00
parent 8a0482472b
commit acdfc02e41
27 changed files with 559 additions and 324 deletions
+9 -5
View File
@@ -52,8 +52,8 @@ export function getColumns(
onClick={(e) => e.stopPropagation()}
/>
),
size: 28,
meta: { className: 'w-8 px-2' },
size: 40,
meta: { className: 'w-10 pl-4 pr-0' },
enableSorting: false,
} satisfies ColumnDef<StorageObject>,
]
@@ -64,7 +64,7 @@ export function getColumns(
cell: ({ row }) => (
<button
type="button"
className="flex items-center gap-2 cursor-pointer bg-transparent border-none p-0"
className="flex w-full min-w-0 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
onClick={() => handlers.onOpen(row.original)}
>
<FileIcon item={row.original} />
@@ -76,6 +76,7 @@ export function getColumns(
if (folderOrder !== 0) return folderOrder
return rowA.getValue<string>(columnId).localeCompare(rowB.getValue<string>(columnId))
},
meta: { flex: true },
},
{
accessorKey: 'size',
@@ -86,7 +87,8 @@ export function getColumns(
if (folderOrder !== 0) return folderOrder
return (rowA.getValue<number>(columnId) ?? 0) - (rowB.getValue<number>(columnId) ?? 0)
},
meta: { className: 'hidden sm:table-cell' },
size: 96,
meta: { className: 'hidden w-24 sm:table-cell' },
},
{
accessorKey: 'updatedAt',
@@ -97,7 +99,8 @@ export function getColumns(
if (folderOrder !== 0) return folderOrder
return new Date(rowA.getValue<string>(columnId)).getTime() - new Date(rowB.getValue<string>(columnId)).getTime()
},
meta: { className: 'hidden md:table-cell' },
size: 160,
meta: { className: 'hidden w-40 md:table-cell' },
},
]
@@ -107,6 +110,7 @@ export function getColumns(
cell: ({ row }) => <FileRowActions item={row.original} handlers={handlers} />,
size: 48,
enableSorting: false,
meta: { className: 'w-12 pr-2 text-right' },
})
}
+52 -42
View File
@@ -9,11 +9,14 @@ import {
type SortingState,
useReactTable,
} from '@tanstack/react-table'
import { FolderOpen } from 'lucide-react'
import { FolderOpen, FolderPlus, Upload } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { PageHeader, type PageHeaderItem } from '@/components/layout/page-header'
import { FilePreviewDialog, type PreviewFile } from '@/components/preview/file-preview-dialog'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { UploadDropzone, type UploadDropzoneHandle } from '@/components/upload/upload-dropzone'
import { getObject, listObjectsByPath } from '@/lib/api'
import { getColumns } from './columns'
@@ -56,7 +59,6 @@ interface FileManagerProps {
download?: (item: StorageObject) => Promise<void> | void
}
capabilities?: {
search?: boolean
selection?: boolean
dragAndDrop?: boolean
upload?: boolean
@@ -87,7 +89,6 @@ export function FileManager({
const breadcrumb = pathToBreadcrumb(currentPath, rootName ?? t('files.title'))
const resolvedCapabilities = useMemo(
() => ({
search: capabilities?.search ?? !dataSource,
selection: capabilities?.selection ?? !dataSource,
dragAndDrop: capabilities?.dragAndDrop ?? !dataSource,
upload: capabilities?.upload ?? !dataSource,
@@ -127,32 +128,22 @@ export function FileManager({
const [shareTarget, setShareTarget] = useState<StorageObject | null>(null)
const [previewFile, setPreviewFile] = useState<PreviewFile | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const query = useQuery({
queryKey: [
...(dataSource?.queryKeyPrefix ?? ['objects', 'active', 'path']),
currentPath,
filterType ?? '',
searchQuery ?? '',
],
queryKey: [...(dataSource?.queryKeyPrefix ?? ['objects', 'active', 'path']), currentPath, filterType ?? ''],
queryFn: () =>
dataSource?.list(currentPath, { filterType, search: searchQuery || undefined }) ??
dataSource?.list(currentPath, { filterType }) ??
listObjectsByPath(currentPath, 'active', 1, FILES_PAGE_SIZE, {
type: filterType,
search: searchQuery || undefined,
}),
})
const mutations = useFileMutations(currentPath)
const conflict = useConflictResolver()
const items = query.data?.items ?? []
// biome-ignore lint/correctness/useExhaustiveDependencies: clear selection and search when path changes
// biome-ignore lint/correctness/useExhaustiveDependencies: clear selection when path changes
useEffect(() => {
setRowSelection({})
setSearchInput('')
setSearchQuery('')
}, [currentPath])
const navigateToPath = useCallback(
@@ -294,27 +285,44 @@ export function FileManager({
)
}
const headerItems: PageHeaderItem[] = breadcrumb.map((item, idx) => {
const isRoot = idx === 0
const isLast = idx === breadcrumb.length - 1
return {
label: item.name,
icon: isRoot ? <FolderOpen className="size-4 text-muted-foreground" /> : undefined,
onClick: !isLast ? () => navigateToPath(item.id) : undefined,
}
})
const headerActions =
resolvedCapabilities.createFolder || resolvedCapabilities.upload ? (
<>
{resolvedCapabilities.createFolder && (
<Button variant="outline" size="sm" onClick={() => setShowNewFolder(true)}>
<FolderPlus />
<span className="sr-only sm:not-sr-only">{t('files.newFolder')}</span>
</Button>
)}
{resolvedCapabilities.upload && (
<Button size="sm" onClick={() => dropzoneRef.current?.openFileDialog()}>
<Upload />
<span className="sr-only sm:not-sr-only">{t('files.upload')}</span>
</Button>
)}
</>
) : null
const content = (
<>
<div className="rounded-2xl border bg-card/95 p-4 shadow-sm backdrop-blur sm:p-5">
<div className="space-y-4">
<PageHeader items={headerItems} actions={headerActions} />
<Card className="gap-0 overflow-hidden py-0 shadow-none">
<FilesToolbar
breadcrumb={breadcrumb}
onNavigate={(path) => navigateToPath(path)}
viewMode={viewMode}
onViewModeChange={setViewMode}
selectedCount={selectedIds.length}
searchQuery={searchInput}
onSearchChange={
resolvedCapabilities.search
? (v) => {
setSearchInput(v)
if (!v) setSearchQuery('')
}
: undefined
}
onSearchSubmit={resolvedCapabilities.search ? () => setSearchQuery(searchInput) : undefined}
onUpload={resolvedCapabilities.upload ? () => dropzoneRef.current?.openFileDialog() : undefined}
onNewFolder={resolvedCapabilities.createFolder ? () => setShowNewFolder(true) : undefined}
totalItems={items.length}
onBatchTrash={resolvedCapabilities.trash ? () => setDeleteTargetIds(selectedIds) : undefined}
onBatchMove={resolvedCapabilities.move ? () => setMoveTargetIds(selectedIds) : undefined}
onClearSelection={resolvedCapabilities.selection ? () => setRowSelection({}) : undefined}
@@ -336,16 +344,18 @@ export function FileManager({
selectionEnabled={resolvedCapabilities.selection}
/>
) : (
<FilesGrid
table={table}
handlers={handlers}
selectedIds={selectedIds}
currentPath={currentPath}
dragAndDropEnabled={resolvedCapabilities.dragAndDrop}
selectionEnabled={resolvedCapabilities.selection}
/>
<div className="p-4">
<FilesGrid
table={table}
handlers={handlers}
selectedIds={selectedIds}
currentPath={currentPath}
dragAndDropEnabled={resolvedCapabilities.dragAndDrop}
selectionEnabled={resolvedCapabilities.selection}
/>
</div>
)}
</div>
</Card>
{resolvedCapabilities.rename ||
resolvedCapabilities.createFolder ||
@@ -420,7 +430,7 @@ export function FileManager({
) : null}
<FilePreviewDialog file={previewFile} open={previewOpen} onOpenChange={setPreviewOpen} />
</>
</div>
)
if (!resolvedCapabilities.upload && !resolvedCapabilities.dragAndDrop) {
+3 -3
View File
@@ -166,14 +166,14 @@ export function FilesTable({ table, handlers, selectedIds, currentPath, dragAndD
const allItems = rows.map((r) => r.original)
return (
<Table>
<Table className="table-fixed">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.column.getSize() }}
style={header.column.columnDef.meta?.flex ? undefined : { width: header.column.getSize() }}
className={cn(
header.column.columnDef.meta?.className,
header.column.getCanSort() && 'cursor-pointer select-none',
+69 -106
View File
@@ -1,137 +1,100 @@
import { FolderInput, FolderPlus, LayoutGrid, List, Search, Share2, Trash2, Upload, X } from 'lucide-react'
import { Copy, Download, FolderInput, LayoutGrid, List, Share2, Trash2, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { FilesBreadcrumb } from './files-breadcrumb'
import type { ViewMode } from './hooks/use-view-mode'
import type { BreadcrumbItem } from './types'
interface FilesToolbarProps {
breadcrumb: BreadcrumbItem[]
onNavigate: (folderId: string) => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
selectedCount: number
searchQuery: string
onSearchChange?: (query: string) => void
onSearchSubmit?: () => void
onUpload?: () => void
onNewFolder?: () => void
totalItems: number
onBatchTrash?: () => void
onBatchMove?: () => void
onBatchCopy?: () => void
onBatchDownload?: () => void
onClearSelection?: () => void
onShare?: () => void
}
export function FilesToolbar({
breadcrumb,
onNavigate,
viewMode,
onViewModeChange,
selectedCount,
searchQuery,
onSearchChange,
onSearchSubmit,
onUpload,
onNewFolder,
totalItems,
onBatchTrash,
onBatchMove,
onBatchCopy,
onBatchDownload,
onClearSelection,
onShare,
}: FilesToolbarProps) {
const { t } = useTranslation()
const searchEnabled = !!(onSearchChange && onSearchSubmit)
const writeActionsEnabled = !!(onUpload || onNewFolder)
const selectionActionsEnabled = !!(onBatchMove || onBatchTrash || onClearSelection)
const selectionActive = selectedCount > 0 && !!(onBatchMove || onBatchTrash || onClearSelection)
if (selectionActive) {
return (
<div data-testid="files-toolbar-selection" className="flex items-center gap-2 border-b bg-primary/5 px-4 py-2">
<span className="text-sm font-medium">{t('files.selectedCount', { count: selectedCount })}</span>
<div className="mx-1 h-5 w-px bg-border" />
{onBatchMove && (
<Button variant="outline" size="icon-sm" onClick={onBatchMove} title={t('files.moveTo')}>
<FolderInput />
</Button>
)}
{onBatchCopy && (
<Button variant="outline" size="icon-sm" onClick={onBatchCopy} title={t('files.copy')}>
<Copy />
</Button>
)}
{onBatchDownload && (
<Button variant="outline" size="icon-sm" onClick={onBatchDownload} title={t('files.download')}>
<Download />
</Button>
)}
{selectedCount === 1 && onShare && (
<Button variant="outline" size="icon-sm" onClick={onShare} title={t('files.share')}>
<Share2 />
</Button>
)}
{onBatchTrash && (
<Button variant="outline" size="icon-sm" onClick={onBatchTrash} title={t('files.moveToTrash')}>
<Trash2 className="text-destructive" />
</Button>
)}
<div className="ml-auto flex items-center">
{onClearSelection && (
<Button variant="ghost" size="icon-sm" onClick={onClearSelection} title={t('common.cancel')}>
<X />
</Button>
)}
</div>
</div>
)
}
return (
<div data-testid="files-toolbar" className="flex flex-wrap items-center justify-between gap-2 py-2">
<FilesBreadcrumb trail={breadcrumb} onNavigate={onNavigate} />
<div
data-testid="files-toolbar"
className="flex items-center justify-between gap-2 border-b bg-background px-4 py-2"
>
<span className="text-sm text-muted-foreground">{t('files.count', { count: totalItems })}</span>
<div className="flex items-center gap-2">
{selectedCount > 0 && selectionActionsEnabled ? (
<>
<span className="hidden text-sm text-muted-foreground sm:inline">
{t('files.selectedCount', { count: selectedCount })}
</span>
{selectedCount === 1 && onShare && (
<Button variant="outline" size="sm" onClick={onShare}>
<Share2 className="h-4 w-4 sm:mr-1" />
<span className="sr-only sm:not-sr-only">{t('files.share')}</span>
</Button>
)}
{onBatchMove && (
<Button variant="outline" size="sm" onClick={onBatchMove}>
<FolderInput className="h-4 w-4 sm:mr-1" />
<span className="sr-only sm:not-sr-only">{t('files.moveTo')}</span>
</Button>
)}
{onBatchTrash && (
<Button variant="destructive" size="sm" onClick={onBatchTrash}>
<Trash2 className="h-4 w-4 sm:mr-1" />
<span className="sr-only sm:not-sr-only">{t('files.moveToTrash')}</span>
</Button>
)}
{onClearSelection && (
<Button variant="outline" size="sm" onClick={onClearSelection}>
<X className="h-4 w-4 sm:mr-1" />
<span className="sr-only sm:not-sr-only">{t('common.cancel')}</span>
</Button>
)}
</>
) : (
<>
{searchEnabled && (
<div className="relative hidden sm:block">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearchSubmit()}
placeholder={t('files.searchPlaceholder')}
className="h-8 w-36 pl-8 pr-8 text-sm lg:w-48"
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => onSearchChange('')}
>
<X className="h-3 w-3" />
</button>
)}
</div>
)}
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={viewMode}
onValueChange={(v) => v && onViewModeChange(v as ViewMode)}
className="hidden sm:flex"
>
<ToggleGroupItem value="list" aria-label="List view">
<List className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="grid" aria-label="Grid view">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
{writeActionsEnabled && onNewFolder && (
<Button variant="outline" size="sm" onClick={onNewFolder}>
<FolderPlus className="h-4 w-4 sm:mr-1" />
<span className="sr-only sm:not-sr-only">{t('files.newFolder')}</span>
</Button>
)}
{writeActionsEnabled && onUpload && (
<Button size="sm" onClick={onUpload}>
<Upload className="h-4 w-4 sm:mr-1" />
<span className="sr-only sm:not-sr-only">{t('files.upload')}</span>
</Button>
)}
</>
)}
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={viewMode}
onValueChange={(v) => v && onViewModeChange(v as ViewMode)}
className="hidden sm:flex"
>
<ToggleGroupItem value="list" aria-label="List view">
<List className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="grid" aria-label="Grid view">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
)
@@ -0,0 +1,18 @@
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export function GlobalSearchBar() {
const { t } = useTranslation()
return (
<div
data-testid="global-search"
className="flex h-9 max-w-[480px] flex-1 items-center gap-2 rounded-md border bg-muted/60 px-3 text-sm text-muted-foreground"
>
<Search className="size-4 shrink-0" />
<span className="flex-1 truncate">{t('common.globalSearchPlaceholder')}</span>
<kbd className="hidden rounded border bg-background px-1.5 py-0.5 text-xs font-medium text-muted-foreground md:inline-block">
K
</kbd>
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
import { Link } from '@tanstack/react-router'
import { Fragment, type ReactNode } from 'react'
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
export type PageHeaderItem = {
label: string
icon?: ReactNode
to?: string
params?: Record<string, string>
onClick?: () => void
}
type PageHeaderProps = {
items: PageHeaderItem[]
actions?: ReactNode
}
const MAX_VISIBLE = 4
type Entry = { kind: 'item'; item: PageHeaderItem } | { kind: 'ellipsis' }
function collapseItems(items: PageHeaderItem[]): Entry[] {
if (items.length <= MAX_VISIBLE) {
return items.map((item) => ({ kind: 'item', item }))
}
return [
{ kind: 'item', item: items[0] },
{ kind: 'ellipsis' },
{ kind: 'item', item: items[items.length - 2] },
{ kind: 'item', item: items[items.length - 1] },
]
}
function renderNavigable(item: PageHeaderItem) {
if (item.to) {
return (
<BreadcrumbLink asChild>
<Link to={item.to as never} params={item.params as never}>
{item.label}
</Link>
</BreadcrumbLink>
)
}
if (item.onClick) {
return (
<BreadcrumbLink asChild>
<button type="button" onClick={item.onClick} className="cursor-pointer bg-transparent">
{item.label}
</button>
</BreadcrumbLink>
)
}
return <span className="text-muted-foreground">{item.label}</span>
}
export function PageHeader({ items, actions }: PageHeaderProps) {
const entries = collapseItems(items)
return (
<div data-testid="page-header" className="flex min-h-9 flex-wrap items-center justify-between gap-3">
<Breadcrumb>
<BreadcrumbList className="gap-2 text-sm sm:gap-2">
{entries.map((entry, idx) => {
const isLast = idx === entries.length - 1
const key = entry.kind === 'ellipsis' ? `ellipsis-${idx}` : `${idx}-${entry.item.label}`
return (
<Fragment key={key}>
{idx > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className="gap-2">
{entry.kind === 'ellipsis' ? (
<BreadcrumbEllipsis className="size-4" />
) : (
<>
{entry.item.icon}
{isLast ? (
<BreadcrumbPage className="text-base font-semibold text-foreground">
{entry.item.label}
</BreadcrumbPage>
) : (
renderNavigable(entry.item)
)}
</>
)}
</BreadcrumbItem>
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
-1
View File
@@ -94,7 +94,6 @@ export function ShareLanding({ token, share, onPasswordRequired }: ShareLandingP
},
}}
capabilities={{
search: false,
selection: false,
dragAndDrop: false,
upload: false,
+5 -4
View File
@@ -3,6 +3,7 @@ import type { StorageObject } from '@shared/types'
import { File, Folder, RotateCcw, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { formatDate, formatSize } from '@/lib/format'
interface TrashListProps {
@@ -26,11 +27,11 @@ export function TrashList({
const allSelected = items.length > 0 && selectedIds.size === items.length
return (
<div className="overflow-x-auto rounded-md border">
<Card className="gap-0 overflow-x-auto py-0 shadow-none">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="w-10 px-4 py-3">
<th className="w-10 pl-4 pr-0 py-3 text-left">
<input
type="checkbox"
checked={allSelected}
@@ -68,7 +69,7 @@ export function TrashList({
)}
</tbody>
</table>
</div>
</Card>
)
}
@@ -90,7 +91,7 @@ function TrashRow({
return (
<tr className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<td className="pl-4 pr-0 py-3">
<input
type="checkbox"
checked={selected}
+23 -25
View File
@@ -1,49 +1,47 @@
import { Trash2 } from 'lucide-react'
import { RotateCcw, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
interface TrashToolbarProps {
selectedCount: number
hasItems: boolean
onRestore: () => void
onDeletePermanently: () => void
onEmptyTrash: () => void
isRestoring: boolean
isDeleting: boolean
isEmptying: boolean
}
export function TrashToolbar({
selectedCount,
hasItems,
onRestore,
onDeletePermanently,
onEmptyTrash,
isRestoring,
isDeleting,
isEmptying,
}: TrashToolbarProps) {
const { t } = useTranslation()
if (selectedCount === 0) return null
return (
<div data-testid="trash-toolbar" className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xl font-semibold">{t('recycleBin.title')}</h2>
<div className="flex items-center gap-2">
{selectedCount > 0 && (
<>
<Button variant="outline" size="sm" onClick={onRestore} disabled={isRestoring}>
{isRestoring ? t('common.loading') : t('recycleBin.restore')}
</Button>
<Button variant="destructive" size="sm" onClick={onDeletePermanently} disabled={isDeleting}>
{isDeleting ? t('common.loading') : t('recycleBin.deletePermanently')}
</Button>
</>
)}
<Button variant="destructive" size="sm" onClick={onEmptyTrash} disabled={!hasItems || isEmptying}>
<Trash2 className="h-4 w-4" />
<span className="sr-only sm:not-sr-only">{t('recycleBin.empty')}</span>
</Button>
</div>
<div data-testid="trash-toolbar" className="flex items-center gap-2 rounded-md border bg-primary/5 px-3 py-2">
<span className="text-sm font-medium">{t('files.selectedCount', { count: selectedCount })}</span>
<div className="mx-1 h-5 w-px bg-border" />
<Button
variant="outline"
size="icon-sm"
onClick={onRestore}
disabled={isRestoring}
title={t('recycleBin.restore')}
>
<RotateCcw className="text-primary" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={onDeletePermanently}
disabled={isDeleting}
title={t('recycleBin.deletePermanently')}
>
<Trash2 className="text-destructive" />
</Button>
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm', className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn('flex items-center px-6 [.border-t]:pt-6', className)} {...props} />
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
+5 -1
View File
@@ -73,7 +73,8 @@
"files.conflictReplaceHint": "The existing file will be moved to Trash.",
"files.conflictApplyToAll": "Apply to all remaining items",
"files.uploadBatchCancelled": "{{count}} remaining upload(s) cancelled",
"recycleBin.title": "Recycle Bin",
"files.count": "{{count}} items",
"recycleBin.title": "Trash",
"recycleBin.placeholder": "Deleted files will appear here.",
"recycleBin.empty": "Empty Trash",
"recycleBin.emptyTitle": "Empty Trash",
@@ -287,6 +288,8 @@
"common.sidebarDescription": "Displays the mobile sidebar.",
"common.toggleSidebar": "Toggle Sidebar",
"common.optional": "optional",
"common.globalSearchPlaceholder": "Search files, shares, teams and settings…",
"common.moreOptions": "More options",
"teams.title": "Teams",
"teams.tabMembers": "Members",
"teams.tabActivity": "Activity",
@@ -428,6 +431,7 @@
"shares.prevPage": "Previous",
"shares.nextPage": "Next",
"shares.pageInfo": "Page {{page}} of {{total}}",
"shares.count": "{{count}} shares",
"share.menuItem": "Share",
"share.title": "Share \"{{name}}\"",
"share.modeLabel": "Share Mode",
+4
View File
@@ -73,6 +73,7 @@
"files.conflictReplaceHint": "原文件将被移至回收站。",
"files.conflictApplyToAll": "对所有剩余项应用此选择",
"files.uploadBatchCancelled": "已取消剩余 {{count}} 个上传",
"files.count": "{{count}} 个项目",
"recycleBin.title": "回收站",
"recycleBin.placeholder": "已删除的文件将显示在这里。",
"recycleBin.empty": "清空回收站",
@@ -287,6 +288,8 @@
"common.sidebarDescription": "显示移动端侧边栏。",
"common.toggleSidebar": "切换侧边栏",
"common.optional": "可选",
"common.globalSearchPlaceholder": "搜索文件、分享、团队和设置…",
"common.moreOptions": "更多选项",
"teams.title": "团队",
"teams.tabMembers": "成员",
"teams.tabActivity": "动态",
@@ -428,6 +431,7 @@
"shares.prevPage": "上一页",
"shares.nextPage": "下一页",
"shares.pageInfo": "第 {{page}} 页,共 {{total}} 页",
"shares.count": "{{count}} 个分享",
"share.menuItem": "分享",
"share.title": "分享 \"{{name}}\"",
"share.modeLabel": "分享方式",
+3 -3
View File
@@ -74,8 +74,8 @@ describe('recycleBin locale keys — interpolation placeholder parity', () => {
})
describe('recycleBin locale keys — English values contract', () => {
it('recycleBin.title is "Recycle Bin"', () => {
expect(enLocale['recycleBin.title']).toBe('Recycle Bin')
it('recycleBin.title is "Trash"', () => {
expect(enLocale['recycleBin.title']).toBe('Trash')
})
it('recycleBin.empty is "Empty Trash"', () => {
@@ -143,7 +143,7 @@ describe('recycleBin locale keys — i18n runtime translation', () => {
it('translates recycleBin.title to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('recycleBin.title')).toBe('Recycle Bin')
expect(i18n.t('recycleBin.title')).toBe('Trash')
})
it('translates recycleBin.title to Chinese', async () => {
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { NameConflictDialog } from '@/components/files/dialogs/name-conflict-dialog'
import { useConflictResolver, withConflictRetry } from '@/components/files/hooks/use-conflict-resolver'
import { PageHeader } from '@/components/layout/page-header'
import { TrashList } from '@/components/trash/trash-list'
import { TrashToolbar } from '@/components/trash/trash-toolbar'
import { Button } from '@/components/ui/button'
@@ -134,15 +135,32 @@ function RecycleBinPage() {
return (
<div className="space-y-4">
<PageHeader
items={[
{
label: t('recycleBin.title'),
icon: <Trash2 className="size-4 text-muted-foreground" />,
},
]}
actions={
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmDialog('empty')}
disabled={items.length === 0 || emptyTrashMutation.isPending}
>
<Trash2 />
<span className="sr-only sm:not-sr-only">{t('recycleBin.empty')}</span>
</Button>
}
/>
<TrashToolbar
selectedCount={selectedIds.size}
hasItems={items.length > 0}
onRestore={() => restoreMutation.mutate([...selectedIds])}
onDeletePermanently={handleDeleteSelected}
onEmptyTrash={() => setConfirmDialog('empty')}
isRestoring={restoreMutation.isPending}
isDeleting={deleteMutation.isPending}
isEmptying={emptyTrashMutation.isPending}
/>
{items.length === 0 ? (
+6 -7
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Outlet, redirect, useMatchRoute } from '@tanstack/react-router'
import { AppSidebar } from '@/components/layout/app-sidebar'
import { GlobalSearchBar } from '@/components/layout/global-search-bar'
import { NotificationBell } from '@/components/notifications/notification-bell'
import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { getSession } from '@/lib/api'
@@ -27,17 +27,16 @@ function AuthenticatedLayout() {
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<span className="text-sm font-medium">ZPan</span>
<div className="ml-auto">
<GlobalSearchBar />
<div className="ml-auto flex items-center">
<NotificationBell />
</div>
</header>
<main className="min-w-0 flex-1 p-4">
<div className="min-w-0 flex-1 bg-canvas p-4">
<Outlet />
</main>
</div>
</SidebarInset>
</SidebarProvider>
)
@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -14,7 +15,7 @@ function AppearancePage() {
return (
<div className="max-w-lg">
<div className="space-y-4 rounded-md border p-4">
<Card className="gap-4 p-4 shadow-none">
<h3 className="text-sm font-medium text-muted-foreground">{t('settings.appearance.section')}</h3>
<div className="space-y-1.5">
@@ -43,7 +44,7 @@ function AppearancePage() {
</SelectContent>
</Select>
</div>
</div>
</Card>
</div>
)
}
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authClient } from '@/lib/auth-client'
@@ -82,10 +83,10 @@ function PasswordPage() {
return (
<div className="max-w-lg">
<div className="space-y-4 rounded-md border p-4">
<Card className="gap-4 p-4 shadow-none">
<h3 className="text-sm font-medium text-muted-foreground">{t('settings.profile.changePassword')}</h3>
<ChangePasswordForm />
</div>
</Card>
</div>
)
}
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authClient, useSession } from '@/lib/auth-client'
@@ -78,10 +79,10 @@ function ProfilePage() {
return (
<div className="max-w-lg">
<div className="space-y-4 rounded-md border p-4">
<Card className="gap-4 p-4 shadow-none">
<h3 className="text-sm font-medium text-muted-foreground">{t('settings.profile.section')}</h3>
<ProfileForm />
</div>
</Card>
</div>
)
}
+10 -1
View File
@@ -1,5 +1,7 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { Settings as SettingsIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PageHeader } from '@/components/layout/page-header'
import { PageTabs } from '@/components/layout/page-tabs'
export const Route = createFileRoute('/_authenticated/settings')({
@@ -17,7 +19,14 @@ function SettingsLayout() {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t('settings.title')}</h2>
<PageHeader
items={[
{
label: t('settings.title'),
icon: <SettingsIcon className="size-4 text-muted-foreground" />,
},
]}
/>
<div className="border-b">
<PageTabs items={tabs} />
</div>
+108 -69
View File
@@ -1,12 +1,22 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { ClipboardCopy, FileIcon, FolderIcon, XCircle } from 'lucide-react'
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, Share2, XCircle } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { PageHeader } from '@/components/layout/page-header'
import { RevokeConfirmDialog } from '@/components/shares/revoke-confirm-dialog'
import { ShareDetailPanel } from '@/components/shares/share-detail-panel'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { deleteShare, listShares, type ShareListItem } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/shares/')({
@@ -75,20 +85,20 @@ function SharesPage() {
const total = sharesQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function setFilter(filter: StatusFilter) {
navigate({ to: '/shares', search: { status: filter, page: 1 } })
}
function setPage(newPage: number) {
navigate({ to: '/shares', search: { status: statusFilter, page: newPage } })
}
const filterTabs: { key: StatusFilter; label: string }[] = [
{ key: 'all', label: t('shares.filterAll') },
{ key: 'active', label: t('shares.filterActive') },
{ key: 'revoked', label: t('shares.filterRevoked') },
{ key: 'expired', label: t('shares.filterExpired') },
]
function setStatus(status: StatusFilter) {
navigate({ to: '/shares', search: { status, page: 1 } })
}
const statusLabel = {
all: t('shares.filterAll'),
active: t('shares.filterActive'),
revoked: t('shares.filterRevoked'),
expired: t('shares.filterExpired'),
}[statusFilter]
if (sharesQuery.isLoading) {
return (
@@ -100,66 +110,95 @@ function SharesPage() {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t('shares.title')}</h2>
<PageHeader
items={[
{
label: t('shares.title'),
icon: <Share2 className="size-4 text-muted-foreground" />,
},
]}
/>
<div className="flex gap-1 border-b pb-0">
{filterTabs.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setFilter(key)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
statusFilter === key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t('shares.colFile')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colType')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colAccess')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colViews')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colDownloads')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colExpires')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colStatus')}</th>
<th className="px-4 py-3 text-right font-medium">{t('shares.colActions')}</th>
</tr>
</thead>
<tbody>
{filteredItems.map((share) => (
<ShareTableRow
key={share.token}
share={share}
displayStatus={computeDisplayStatus(share)}
onRowClick={() => setDetailShare(share)}
onCopyUrl={() => {
const base = window.location.origin
const url = share.kind === 'landing' ? `${base}/s/${share.token}` : `${base}/dl/${share.token}`
navigator.clipboard.writeText(url)
toast.success(t('shares.urlCopied'))
}}
onRevoke={() => setRevokeTarget(share)}
/>
))}
{filteredItems.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-muted-foreground">
<p className="font-medium">{t('shares.emptyState')}</p>
<p className="mt-1 text-xs">{t('shares.emptyStateHint')}</p>
</td>
<Card className="gap-0 overflow-hidden py-0 shadow-none">
<div
data-testid="shares-toolbar"
className="flex items-center justify-between gap-2 border-b bg-background px-3 py-2"
>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label={t('shares.colStatus')}>
<span className="text-muted-foreground">{t('shares.colStatus')}:</span>
<span className="font-medium">{statusLabel}</span>
<ChevronDown className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>{t('shares.colStatus')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{(['all', 'active', 'revoked', 'expired'] as const).map((key) => (
<DropdownMenuCheckboxItem
key={key}
checked={statusFilter === key}
onCheckedChange={() => setStatus(key)}
>
{
{
all: t('shares.filterAll'),
active: t('shares.filterActive'),
revoked: t('shares.filterRevoked'),
expired: t('shares.filterExpired'),
}[key]
}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<span className="text-sm text-muted-foreground">{t('shares.count', { count: total })}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t('shares.colFile')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colType')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colAccess')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colViews')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colDownloads')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colExpires')}</th>
<th className="px-4 py-3 text-left font-medium">{t('shares.colStatus')}</th>
<th className="px-4 py-3 text-right font-medium">{t('shares.colActions')}</th>
</tr>
)}
</tbody>
</table>
</div>
</thead>
<tbody>
{filteredItems.map((share) => (
<ShareTableRow
key={share.token}
share={share}
displayStatus={computeDisplayStatus(share)}
onRowClick={() => setDetailShare(share)}
onCopyUrl={() => {
const base = window.location.origin
const url = share.kind === 'landing' ? `${base}/s/${share.token}` : `${base}/dl/${share.token}`
navigator.clipboard.writeText(url)
toast.success(t('shares.urlCopied'))
}}
onRevoke={() => setRevokeTarget(share)}
/>
))}
{filteredItems.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-muted-foreground">
<p className="font-medium">{t('shares.emptyState')}</p>
<p className="mt-1 text-xs">{t('shares.emptyStateHint')}</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
@@ -4,6 +4,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { listTeamActivities } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/teams/$teamId/activity')({
@@ -125,11 +126,11 @@ function TeamActivityPage() {
{t('activity.empty')}
</div>
) : (
<div className="divide-y rounded-md border px-4">
<Card className="gap-0 divide-y px-4 py-0 shadow-none">
{allItems.map((event) => (
<ActivityItem key={event.id} event={event} />
))}
</div>
</Card>
)}
{hasNextPage && (
@@ -3,9 +3,9 @@ import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { InviteDialog } from '@/components/team/invite-dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
Dialog,
DialogContent,
@@ -199,7 +199,7 @@ function MemberRow({
const joinedDate = new Date(member.createdAt as string).toLocaleDateString()
return (
<div className="flex items-center justify-between gap-4 rounded-md border p-3">
<Card className="flex-row items-center justify-between gap-4 p-3 shadow-none">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-9 w-9 shrink-0">
<AvatarImage src={member.user.image ?? undefined} />
@@ -255,7 +255,7 @@ function MemberRow({
</div>
<RemoveMemberDialog open={removeOpen} onOpenChange={setRemoveOpen} member={member} orgId={orgId} />
</div>
</Card>
)
}
@@ -264,7 +264,6 @@ function TeamMembersPage() {
const { teamId } = Route.useParams()
const { data: session } = useSession()
const [leaveOpen, setLeaveOpen] = useState(false)
const [inviteOpen, setInviteOpen] = useState(false)
const {
data: org,
@@ -309,15 +308,8 @@ function TeamMembersPage() {
<div className="space-y-6">
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">{t('teams.memberCount', { count: org.members.length })}</span>
{isOwner && (
<Button type="button" size="sm" onClick={() => setInviteOpen(true)}>
{t('teams.invite.button')}
</Button>
)}
</div>
{isOwner && <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} orgId={org.id} />}
<div className="space-y-2">
{org.members.map((member) => (
<MemberRow
@@ -1,15 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link, Outlet, useParams } from '@tanstack/react-router'
import { createFileRoute, Outlet, useParams } from '@tanstack/react-router'
import { UserPlus, Users } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PageHeader } from '@/components/layout/page-header'
import { PageTabs } from '@/components/layout/page-tabs'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { InviteDialog } from '@/components/team/invite-dialog'
import { Button } from '@/components/ui/button'
import { authClient, useSession } from '@/lib/auth-client'
export const Route = createFileRoute('/_authenticated/teams/$teamId')({
@@ -26,6 +23,7 @@ function TeamLayout() {
const { t } = useTranslation()
const { teamId } = useParams({ from: '/_authenticated/teams/$teamId' })
const { data: session } = useSession()
const [inviteOpen, setInviteOpen] = useState(false)
const {
data: org,
@@ -78,25 +76,32 @@ function TeamLayout() {
return (
<div className="space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/teams">{t('nav.teams')}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{org.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeader
items={[
{
label: t('nav.teams'),
icon: <Users className="size-4 text-muted-foreground" />,
to: '/teams',
},
{ label: org.name },
]}
actions={
isOwner ? (
<Button size="sm" onClick={() => setInviteOpen(true)}>
<UserPlus />
<span className="sr-only sm:not-sr-only">{t('teams.invite.button')}</span>
</Button>
) : null
}
/>
<div className="border-b">
<PageTabs items={tabs} />
</div>
<Outlet />
{isOwner && <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} orgId={org.id} />}
</div>
)
}
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
Dialog,
DialogContent,
@@ -203,24 +204,24 @@ function TeamSettingsPage() {
}
if (!isOwner) {
return <div className="rounded-md border p-4 text-sm text-muted-foreground">{t('teams.ownerOnly')}</div>
return <Card className="gap-0 p-4 text-sm text-muted-foreground shadow-none">{t('teams.ownerOnly')}</Card>
}
return (
<div className="space-y-6">
<div className="max-w-lg space-y-6">
<div className="space-y-4 rounded-md border p-4">
<Card className="gap-4 p-4 shadow-none">
<h3 className="text-sm font-medium text-muted-foreground">{t('teams.generalSection')}</h3>
<TeamSettingsForm org={org} />
</div>
</Card>
<div className="space-y-4 rounded-md border border-destructive/30 p-4">
<Card className="gap-4 border-destructive/30 p-4 shadow-none">
<h3 className="text-sm font-medium text-destructive">{t('teams.dangerZone')}</h3>
<p className="text-sm text-muted-foreground">{t('teams.deleteWarning')}</p>
<Button type="button" variant="destructive" onClick={() => setDeleteOpen(true)}>
{t('teams.deleteTeam')}
</Button>
</div>
</Card>
</div>
<DeleteTeamDialog open={deleteOpen} onOpenChange={setDeleteOpen} orgId={org.id} orgName={org.name} />
+16 -8
View File
@@ -7,6 +7,7 @@ import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { PageHeader } from '@/components/layout/page-header'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
@@ -140,7 +141,7 @@ function TeamCard({ org, userId }: { org: TeamCardOrg; userId: string }) {
<button
type="button"
onClick={() => navigate({ to: '/teams/$teamId', params: { teamId: org.id } })}
className="flex w-full items-start gap-4 rounded-md border p-4 text-left transition-colors hover:bg-accent"
className="flex w-full items-start gap-4 rounded-md border bg-card p-4 text-left transition-colors hover:bg-accent"
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
{org.logo ? (
@@ -193,13 +194,20 @@ function TeamsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('teams.title')}</h2>
<Button onClick={() => setCreateOpen(true)} size="sm">
<Plus className="mr-1.5 h-4 w-4" />
{t('teams.createNew')}
</Button>
</div>
<PageHeader
items={[
{
label: t('teams.title'),
icon: <Users className="size-4 text-muted-foreground" />,
},
]}
actions={
<Button onClick={() => setCreateOpen(true)} size="sm">
<Plus />
<span className="sr-only sm:not-sr-only">{t('teams.createNew')}</span>
</Button>
}
/>
{isPending ? (
<div className="space-y-2">
+4
View File
@@ -11,6 +11,8 @@
--color-card-foreground: oklch(0.22 0 0);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.22 0 0);
/* Page canvas — the tinted layer behind elevated cards */
--color-canvas: oklch(0.978 0.006 247);
/* ── Primary (Google Blue) ─────────────────────────── */
--color-primary: oklch(0.587 0.201 258.4);
@@ -55,6 +57,8 @@
--color-card-foreground: oklch(0.93 0.005 250);
--color-popover: oklch(0.27 0.003 250);
--color-popover-foreground: oklch(0.93 0.005 250);
/* Canvas darker than card to keep "card lifts off canvas" contrast */
--color-canvas: oklch(0.18 0.005 250);
/* ── Primary (Google Blue · dark variant #8ab4f8) ──── */
--color-primary: oklch(0.764 0.111 254.1);
+1
View File
@@ -5,5 +5,6 @@ import '@tanstack/react-table'
declare module '@tanstack/react-table' {
interface ColumnMeta<TData, TValue> {
className?: string
flex?: boolean
}
}