Updated the dialog components to use a unified class name, replacing 'crt-panel' with 'crt-dialog' for better clarity and consistency across the application. Enhanced the dialog overlay with a denser background and blur effect to improve readability against underlying content. Additionally, modified the UploadSnackbar and ShowsPanel to reflect these changes, ensuring a cohesive user experience.
142 lines
5.0 KiB
TypeScript
142 lines
5.0 KiB
TypeScript
import { useTranslation } from 'react-i18next'
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Clock,
|
|
Loader2,
|
|
RotateCw,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/shared/lib/cn'
|
|
import { type UploadItem, useUploadStore } from './upload-store'
|
|
|
|
function StatusIcon({ status }: Readonly<{ status: UploadItem['status'] }>) {
|
|
switch (status) {
|
|
case 'done':
|
|
return <Check className="h-3.5 w-3.5 shrink-0 text-primary" />
|
|
case 'error':
|
|
return <AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
|
|
case 'uploading':
|
|
return <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
|
default:
|
|
return <Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
}
|
|
}
|
|
|
|
function barWidth(item: UploadItem): number {
|
|
if (item.status === 'done' || item.status === 'error') return 100
|
|
if (item.status === 'uploading') return item.percent
|
|
return 0
|
|
}
|
|
|
|
/** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */
|
|
export function UploadSnackbar() {
|
|
const { t } = useTranslation()
|
|
const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll, retry } =
|
|
useUploadStore()
|
|
|
|
if (items.length === 0 && skipped === 0) return null
|
|
|
|
const done = items.filter((i) => i.status === 'done').length
|
|
const hasItems = items.length > 0
|
|
|
|
let header: string
|
|
if (!hasItems) header = t('admin.media.skippedDuplicates', { count: skipped })
|
|
else if (active) header = t('admin.media.uploadingCount', { done, total: items.length })
|
|
else header = t('admin.media.uploadedCount', { count: done })
|
|
|
|
return (
|
|
<div className="crt-dialog fixed bottom-4 right-4 z-50 w-96 max-w-[calc(100vw-2rem)] rounded-md">
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm">
|
|
<span className="truncate font-medium">{header}</span>
|
|
{hasItems && (
|
|
<button
|
|
type="button"
|
|
className="ml-auto opacity-70 hover:opacity-100"
|
|
onClick={toggleMinimize}
|
|
>
|
|
{minimized ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</button>
|
|
)}
|
|
{active && (
|
|
<button
|
|
type="button"
|
|
className="opacity-70 hover:opacity-100"
|
|
title={t('admin.media.cancelAll')}
|
|
onClick={cancelAll}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
{!active && (
|
|
<button
|
|
type="button"
|
|
className={cn('opacity-70 hover:opacity-100', !hasItems && 'ml-auto')}
|
|
onClick={dismiss}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{hasItems && skipped > 0 && (
|
|
<div className="border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
|
|
{t('admin.media.skippedDuplicates', { count: skipped })}
|
|
</div>
|
|
)}
|
|
|
|
{hasItems && !minimized && (
|
|
<ul className="max-h-72 divide-y divide-border overflow-y-auto">
|
|
{items.map((item) => (
|
|
<li key={item.id} className="flex flex-col gap-1.5 px-3 py-2 text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<StatusIcon status={item.status} />
|
|
<span className="truncate" title={item.name}>
|
|
{item.name}
|
|
</span>
|
|
{item.status === 'uploading' && (
|
|
<span className="ml-auto shrink-0 text-muted-foreground">{item.percent}%</span>
|
|
)}
|
|
{(item.status === 'queued' || item.status === 'uploading') && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'shrink-0 opacity-70 hover:opacity-100',
|
|
item.status === 'queued' && 'ml-auto',
|
|
)}
|
|
title={t('common.cancel')}
|
|
onClick={() => cancel(item.id)}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{item.status === 'error' && (
|
|
<button
|
|
type="button"
|
|
className="ml-auto shrink-0 opacity-70 hover:opacity-100"
|
|
title={t('common.retry')}
|
|
onClick={() => retry(item.id)}
|
|
>
|
|
<RotateCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="h-1 w-full overflow-hidden rounded bg-muted">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded transition-[width]',
|
|
item.status === 'error' ? 'bg-red-500' : 'bg-primary',
|
|
)}
|
|
style={{ width: `${barWidth(item)}%` }}
|
|
/>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|