Repository Public
1// Files with identical content inside the account, grouped; the extra copies of a group can go to the trash.2import { formatBytes } from '@spd/shared';3import { useQuery } from '@tanstack/react-query';4import { useState } from 'react';5import { Link } from 'wouter';6import { get } from '../api.ts';7import { fmtDate, getLocale, useT } from '../i18n.ts';8import { Thumb } from '../preview/Thumb.tsx';9import { ownerSource } from '../sources.ts';10import type { FileDetail, FileItem } from '../types.ts';11import { Badge, Button, EmptyState, ErrorState, IconButton, Loading } from '../ui.tsx';12import { pathText } from './Details.tsx';13import { XKEY } from './lib.ts';1415interface Group { sha256: string; size: number; files: FileItem[] }1617/** Where a file lives. One request per folder, whatever the number of copies in it. */18function Location({ file }: { file: FileItem }) {19 const t = useT();20 const q = useQuery({ queryKey: [XKEY, 'path', file.folderId], queryFn: async () => (await get<FileDetail>(`/files/${file.id}`)).path, staleTime: 60_000 });21 if (!q.data) return <span className="text-ink-3">{q.isLoading ? '…' : ''}</span>;22 return <Link href={q.data.length <= 1 ? '/' : `/folder/${file.folderId}`} className="truncate text-ink-2 underline-offset-2 hover:text-accent hover:underline">{pathText(q.data, t('explorer.root'))}</Link>;23}2425export function Duplicates({ onOpen, onTrash }: { onOpen: (f: FileItem) => void; onTrash: (files: FileItem[]) => void }) {26 const t = useT();27 const loc = getLocale();28 const [shown, setShown] = useState(30);29 const q = useQuery({ queryKey: [XKEY, 'duplicates'], queryFn: () => get<{ groups: Group[] }>('/duplicates') });30 if (q.isLoading) return <Loading />;31 if (q.error || !q.data) return <ErrorState error={q.error} onRetry={() => void q.refetch()} />;32 const groups = q.data.groups;33 if (!groups.length) return <EmptyState icon="duplicate" title={t('explorer.empty.duplicates')} text={t('explorer.empty.duplicatesText')} />;34 const extra = groups.reduce((n, g) => n + g.size * (g.files.length - 1), 0);35 return (36 <div className="flex flex-col gap-3">37 <p className="text-sm text-ink-2">{t('explorer.dup.summary', { n: groups.length, size: formatBytes(extra, loc) })} <span className="text-ink-3">{t('explorer.dup.note')}</span></p>38 {groups.slice(0, shown).map((g) => (39 <section key={`${g.sha256}:${g.size}`} aria-label={g.files[0]!.name} className="rounded-card border border-line bg-surface" data-testid="dup-group">40 <header className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2">41 <Badge tone="accent">{t('explorer.dup.copies', { n: g.files.length })}</Badge>42 <span className="min-w-0 flex-1 text-sm text-ink-2">{t('explorer.dup.each', { size: formatBytes(g.size, loc) })} · {t('explorer.dup.saved', { size: formatBytes(g.size * (g.files.length - 1), loc) })}</span>43 <Button size="sm" icon="trash" onClick={() => onTrash(g.files.slice(1))}>{t('explorer.dup.trashExtra', { n: g.files.length - 1 })}</Button>44 </header>45 <ul className="divide-y divide-line">46 {g.files.map((f, i) => (47 <li key={f.id} className="flex items-center gap-3 px-3 py-1.5">48 <Thumb name={f.name} kind={f.kind} className="h-9 w-9 shrink-0 overflow-hidden rounded-lg" src={f.thumb && f.scan !== 'infected' ? ownerSource(f).previewUrl('thumb') : undefined} />49 <div className="min-w-0 flex-1">50 <p className="flex items-center gap-2 text-sm font-medium text-ink"><span className="truncate">{f.name}</span>{i === 0 ? <Badge tone="ok">{t('explorer.dup.kept')}</Badge> : null}</p>51 <p className="flex min-w-0 gap-1 text-xs text-ink-3"><Location file={f} /><span className="shrink-0">· {fmtDate(f.createdAt, { dateStyle: 'medium' })}</span></p>52 </div>53 {f.scan !== 'infected' ? <IconButton icon="eye" size={18} label={t('explorer.openNamed', { name: f.name })} onClick={() => onOpen(f)} /> : null}54 <IconButton icon="trash" size={18} label={t('explorer.trashNamed', { name: f.name })} onClick={() => onTrash([f])} />55 </li>56 ))}57 </ul>58 </section>59 ))}60 {groups.length > shown ? <Button className="self-center" onClick={() => setShown(shown + 30)}>{t('explorer.dup.more', { n: groups.length - shown })}</Button> : null}61 </div>62 );63}64