mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 08:16:58 +08:00
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:
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,6 @@ export function ShareLanding({ token, share, onPasswordRequired }: ShareLandingP
|
||||
},
|
||||
}}
|
||||
capabilities={{
|
||||
search: false,
|
||||
selection: false,
|
||||
dragAndDrop: false,
|
||||
upload: false,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "分享方式",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+1
@@ -5,5 +5,6 @@ import '@tanstack/react-table'
|
||||
declare module '@tanstack/react-table' {
|
||||
interface ColumnMeta<TData, TValue> {
|
||||
className?: string
|
||||
flex?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user