Repository Public
1// Floating list of transfers (bottom-right on desktop, bottom sheet on phones) and the name-collision question.2// Positioned inside the listing column of the explorer: above the phone navigation bar, beside the details panel.3import { formatBytes } from '@spd/shared';4import { memo, useState } from 'react';5import { Glyph } from '../glyphs.tsx';6import { errorText, getLocale, useT } from '../i18n.ts';7import { Button, cx, Dialog, IconButton, ProgressBar, Spinner } from '../ui.tsx';8import { type Decision, type Pending, type UploadItem, uploads, useUploads } from './uploads.ts';910function eta(seconds: number, t: ReturnType<typeof useT>): string {11 if (!Number.isFinite(seconds) || seconds < 1) return t('explorer.up.etaSoon');12 if (seconds < 60) return t('explorer.up.etaS', { n: Math.round(seconds) });13 if (seconds < 3600) return t('explorer.up.etaM', { n: Math.round(seconds / 60) });14 return t('explorer.up.etaH', { n: Math.round(seconds / 360) / 10 });15}1617const Row = memo(function Row({ it }: { it: UploadItem }) {18 const t = useT();19 const loc = getLocale();20 const pct = it.size ? it.sent / it.size : it.state === 'done' ? 1 : 0;21 const resumed = it.resumedFrom > 0 ? ` · ${t('explorer.up.resumedAt', { pct: Math.round((it.resumedFrom / Math.max(1, it.size)) * 100) })}` : '';22 let line: string;23 if (it.state === 'queued') line = t('explorer.up.queued');24 else if (it.state === 'uploading') line = `${t('explorer.up.progress', { sent: formatBytes(it.sent, loc), total: formatBytes(it.size, loc) })}${it.speed > 0 ? ` · ${formatBytes(it.speed, loc)}/s · ${eta((it.size - it.sent) / it.speed, t)}` : ''}${resumed}`;25 else if (it.state === 'paused') line = `${t('explorer.up.paused')} · ${t('explorer.up.progress', { sent: formatBytes(it.sent, loc), total: formatBytes(it.size, loc) })}`;26 else if (it.state === 'finishing') line = t('explorer.up.finishing');27 else if (it.state === 'conflict') line = t('explorer.up.conflictWaiting');28 else if (it.state === 'skipped') line = t('explorer.up.skipped');29 else if (it.state === 'done') line = `${it.created === false ? t('explorer.up.doneVersion') : it.result && it.result.name !== it.name ? t('explorer.up.doneAs', { name: it.result.name }) : t('explorer.up.done')} · ${formatBytes(it.size, loc)}${resumed}`;30 else line = errorText(it.error);31 return (32 <li className="flex items-center gap-3 py-2.5 pr-2 pl-4" data-upload-state={it.state}>33 <span className={cx('flex h-10 w-10 shrink-0 items-center justify-center rounded-[13px] transition-colors duration-300', it.state === 'done' ? 'bg-ok-soft text-ok' : it.state === 'error' ? 'bg-danger-soft text-danger' : it.state === 'uploading' || it.state === 'finishing' ? 'grad' : 'bg-hover text-ink-2')}>34 {it.state === 'finishing' ? <Spinner size={16} /> : <Glyph size={18} name={it.state === 'done' ? 'check' : it.state === 'error' ? 'warning' : it.state === 'paused' ? 'pause' : it.state === 'skipped' ? 'x' : 'upload'} />}35 </span>36 <div className="min-w-0 flex-1">37 <p className="truncate text-[15px] font-medium text-ink sm:text-sm" title={it.relativePath ?? it.name}>{it.name}</p>38 <p className={cx('truncate text-xs tabular-nums', it.state === 'error' ? 'text-danger' : 'text-ink-3')} role={it.state === 'error' ? 'alert' : undefined}>{line}</p>39 {it.state === 'uploading' || it.state === 'paused' ? <div className="mt-1.5"><ProgressBar value={pct} label={it.name} /></div> : null}40 </div>41 {it.state === 'uploading' || it.state === 'queued' ? <IconButton icon="pause" label={t('explorer.up.pause')} size={18} onClick={() => uploads.pause(it.key)} /> : null}42 {it.state === 'paused' ? <IconButton icon="play" label={t('explorer.up.resume')} size={18} onClick={() => uploads.resume(it.key)} /> : null}43 {it.state === 'error' ? <IconButton icon="refresh" label={t('common.retry')} size={18} onClick={() => uploads.resume(it.key)} /> : null}44 {it.state !== 'finishing' ? <IconButton icon="x" label={it.state === 'done' || it.state === 'skipped' ? t('explorer.up.remove') : t('explorer.up.cancel')} size={18} onClick={() => uploads.cancel(it.key)} /> : null}45 </li>46 );47});4849function PendingRow({ p }: { p: Pending }) {50 const t = useT();51 return (52 <li className="flex items-center gap-3 px-4 py-2">53 <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[13px] bg-warn-soft text-warn"><Glyph name="history" size={18} /></span>54 <div className="min-w-0 flex-1"><p className="truncate text-sm text-ink">{p.name}</p><p className="text-xs text-ink-3">{formatBytes(p.size, getLocale())}</p></div>55 <Button size="sm" variant="ghost" onClick={() => uploads.forget(p.uploadId)}>{t('explorer.up.forget')}</Button>56 </li>57 );58}5960function ConflictDialog({ it, many }: { it: UploadItem; many: boolean }) {61 const t = useT();62 const [all, setAll] = useState(many);63 const pick = (d: Decision) => uploads.resolve(it.key, d, all);64 return (65 <Dialog title={t('explorer.conflict.title')} onClose={() => pick('skip')} dismissable={false}>66 <p className="text-sm leading-relaxed text-ink-2">{t('explorer.conflict.text', { name: it.name })}</p>67 <div className="mt-4 flex flex-col gap-2">68 <Button variant="primary" icon="history" onClick={() => pick('version')} data-autofocus>{t('explorer.conflict.version')}</Button>69 <Button icon="duplicate" onClick={() => pick('rename')}>{t('explorer.conflict.keepBoth')}</Button>70 <Button variant="ghost" onClick={() => pick('skip')}>{t('explorer.conflict.skip')}</Button>71 </div>72 {many ? (73 <label className="mt-4 flex min-h-10 cursor-pointer items-center gap-3 text-sm text-ink">74 <input type="checkbox" className="h-4 w-4 accent-[var(--accent)]" checked={all} onChange={(e) => setAll(e.target.checked)} />75 {t('explorer.conflict.applyAll')}76 </label>77 ) : null}78 </Dialog>79 );80}8182/** Overall progress as a ring with the percentage inside. */83function Ring({ value }: { value: number }) {84 const v = Math.max(0, Math.min(1, value)), r = 15, c = 2 * Math.PI * r;85 return (86 <span className="relative flex h-9 w-9 shrink-0 items-center justify-center" aria-hidden="true">87 <svg width="36" height="36" viewBox="0 0 36 36" className="-rotate-90">88 <defs><linearGradient id="spd-ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="var(--grad-a)" /><stop offset="1" stopColor="var(--grad-b)" /></linearGradient></defs>89 <circle cx="18" cy="18" r={r} fill="none" stroke="var(--hover)" strokeWidth="3.5" />90 <circle cx="18" cy="18" r={r} fill="none" stroke="url(#spd-ring)" strokeWidth="3.5" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - v)} className="transition-[stroke-dashoffset] duration-300" />91 </svg>92 <span className="absolute text-[10px] font-bold text-ink tabular-nums">{Math.round(v * 100)}</span>93 </span>94 );95}9697let panelOpen = true; // survives the remount of the explorer between folders98/** True while the panel takes room at the bottom of the screen (the listing adds padding on phones). */99export function useUploadPanelVisible(): boolean { const s = useUploads(); return s.items.length > 0 || s.pending.length > 0; }100101export function UploadPanel() {102 const t = useT();103 const { items, pending, batchSizes } = useUploads();104 const [open, setOpenState] = useState(panelOpen);105 const setOpen = (v: boolean) => { panelOpen = v; setOpenState(v); };106 const [hidePending, setHidePending] = useState(false);107 const conflict = items.find((i) => i.state === 'conflict');108 const showPending = pending.length > 0 && !hidePending;109 if (!items.length && !showPending) return null;110 const active = items.filter(uploads.isActive), paused = items.filter((i) => i.state === 'paused'), failed = items.filter((i) => i.state === 'error');111 const finished = items.filter((i) => i.state === 'done' || i.state === 'skipped');112 const total = items.reduce((n, i) => n + i.size, 0), sent = items.reduce((n, i) => n + (i.state === 'done' || i.state === 'skipped' ? i.size : i.sent), 0);113 const title = active.length ? t('explorer.up.titleActive', { n: active.length })114 : failed.length ? t('explorer.up.titleFailed', { n: failed.length })115 : paused.length ? t('explorer.up.titlePaused', { n: paused.length })116 : items.length ? t('explorer.up.titleDone', { n: finished.filter((i) => i.state === 'done').length })117 : t('explorer.up.titlePending', { n: pending.length });118 return (119 <>120 <section aria-label={t('explorer.up.label')} data-testid="upload-panel" style={{ bottom: 'calc(var(--dock) + 10px)' }}121 className="anim-pop absolute inset-x-3 z-40 flex max-h-[55%] flex-col overflow-hidden rounded-[24px] bg-raised shadow-pop ring-1 ring-(--glass-line) sm:inset-x-auto sm:right-4 sm:max-h-[70%] sm:w-[26rem]">122 <header className="flex items-center gap-1 py-2 pr-2 pl-4">123 {active.length || paused.length ? <Ring value={total ? sent / total : 0} /> : <span className={cx('flex h-9 w-9 shrink-0 items-center justify-center rounded-full', failed.length ? 'bg-danger-soft text-danger' : 'bg-ok-soft text-ok')}><Glyph name={failed.length ? 'warning' : items.length ? 'check' : 'history'} size={18} strokeWidth={2.2} /></span>}124 <h2 className="min-w-0 flex-1 truncate pl-2 text-sm font-bold tracking-tight text-ink sm:text-[15px]" aria-live="polite">{title}</h2>125 {finished.length ? <Button size="sm" variant="ghost" onClick={() => uploads.clearFinished()}>{t('explorer.up.clear')}</Button> : null}126 <IconButton icon={open ? 'chevronDown' : 'arrowUp'} label={open ? t('explorer.up.collapse') : t('explorer.up.expand')} size={18} aria-expanded={open} onClick={() => setOpen(!open)} />127 </header>128 {active.length || paused.length ? <div className="px-4 pb-2.5"><ProgressBar value={total ? sent / total : 0} label={t('explorer.up.overall')} /></div> : null}129 {open ? (130 <div className="min-h-0 flex-1 overflow-y-auto border-t border-line">131 {items.length ? <ul className="divide-y divide-line">{items.map((it) => <Row key={it.key} it={it} />)}</ul> : null}132 {showPending ? (133 <div className={cx(items.length > 0 && 'border-t border-line')}>134 <div className="flex items-start gap-2 px-4 pt-3">135 <div className="min-w-0 flex-1">136 <h3 className="text-xs font-semibold tracking-wide text-ink-2 uppercase">{t('explorer.up.pendingTitle', { n: pending.length })}</h3>137 <p className="mt-1 text-xs leading-relaxed text-ink-3">{t('explorer.up.pendingText')}</p>138 </div>139 <IconButton icon="x" label={t('explorer.up.pendingHide')} size={16} className="-mt-1.5 -mr-2" onClick={() => setHidePending(true)} />140 </div>141 <ul className="pb-1">{pending.map((p) => <PendingRow key={p.uploadId} p={p} />)}</ul>142 </div>143 ) : null}144 <p className="border-t border-line px-4 py-2 text-xs leading-relaxed text-ink-3">{t('explorer.up.note')}</p>145 </div>146 ) : null}147 </section>148 {conflict ? <ConflictDialog key={conflict.key} it={conflict} many={(batchSizes[conflict.batch] ?? 1) > 1} /> : null}149 </>150 );151}152