Files
TeleWave/frontend/src/features/admin/media/UploadSnackbar.tsx
T
Leonid PershinandClaude Opus 5 0606ea3e6e Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:38:12 +03:00

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-panel fixed bottom-4 right-4 z-50 w-96 max-w-[calc(100vw-2rem)] rounded-md shadow-lg">
<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>
)
}