/ src / views / settings / storage-browser.tsx
storage-browser.tsx
  1  'use client'
  2  
  3  import { useState, useMemo } from 'react'
  4  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
  5  import { formatBytes } from '@/lib/format-display'
  6  
  7  interface UploadFile {
  8    name: string
  9    size: number
 10    modified: number
 11    category: string
 12    url: string
 13  }
 14  
 15  type SortField = 'modified' | 'size' | 'name'
 16  
 17  interface Props {
 18    files: UploadFile[]
 19    onDelete: (filenames: string[]) => void
 20  }
 21  
 22  
 23  
 24  function formatDate(ms: number): string {
 25    const d = new Date(ms)
 26    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
 27  }
 28  
 29  const CATEGORY_ICONS: Record<string, string> = {
 30    image: '\u{1F5BC}',
 31    video: '\u{1F3AC}',
 32    audio: '\u{1F3B5}',
 33    document: '\u{1F4C4}',
 34    archive: '\u{1F4E6}',
 35    other: '\u{1F4CE}',
 36  }
 37  
 38  const CATEGORY_LABELS: Record<string, string> = {
 39    image: 'Images',
 40    video: 'Videos',
 41    audio: 'Audio',
 42    document: 'Docs',
 43    archive: 'Archives',
 44    other: 'Other',
 45  }
 46  
 47  export function StorageBrowser({ files, onDelete }: Props) {
 48    const [selected, setSelected] = useState<Set<string>>(new Set())
 49    const [sortBy, setSortBy] = useState<SortField>('modified')
 50    const [filterCategory, setFilterCategory] = useState<string | null>(null)
 51    const [confirmDelete, setConfirmDelete] = useState<string[] | null>(null)
 52  
 53    const categories = useMemo(() => {
 54      const cats = new Set<string>()
 55      for (const f of files) cats.add(f.category)
 56      return Array.from(cats).sort()
 57    }, [files])
 58  
 59    const filtered = useMemo(() => {
 60      let list = filterCategory ? files.filter((f) => f.category === filterCategory) : files
 61      list = [...list].sort((a, b) => {
 62        if (sortBy === 'modified') return b.modified - a.modified
 63        if (sortBy === 'size') return b.size - a.size
 64        return a.name.localeCompare(b.name)
 65      })
 66      return list
 67    }, [files, filterCategory, sortBy])
 68  
 69    const totalSize = useMemo(() => files.reduce((s, f) => s + f.size, 0), [files])
 70  
 71    const toggleSelect = (name: string) => {
 72      setSelected((prev) => {
 73        const next = new Set(prev)
 74        if (next.has(name)) next.delete(name)
 75        else next.add(name)
 76        return next
 77      })
 78    }
 79  
 80    const toggleSelectAll = () => {
 81      if (selected.size === filtered.length) {
 82        setSelected(new Set())
 83      } else {
 84        setSelected(new Set(filtered.map((f) => f.name)))
 85      }
 86    }
 87  
 88    const handleDeleteSelected = () => {
 89      const names = Array.from(selected)
 90      if (names.length > 0) setConfirmDelete(names)
 91    }
 92  
 93    const executeDelete = () => {
 94      if (confirmDelete) {
 95        onDelete(confirmDelete)
 96        setSelected((prev) => {
 97          const next = new Set(prev)
 98          for (const name of confirmDelete) next.delete(name)
 99          return next
100        })
101        setConfirmDelete(null)
102      }
103    }
104  
105    return (
106      <div>
107        {/* Header */}
108        <div className="flex items-center justify-between mb-5">
109          <div>
110            <h3 className="font-display text-[18px] font-700 tracking-[-0.02em] text-text">File Browser</h3>
111            <p className="text-[12px] text-text-3 mt-0.5">
112              {files.length} file{files.length !== 1 ? 's' : ''} &middot; {formatBytes(totalSize)}
113            </p>
114          </div>
115          <select
116            value={sortBy}
117            onChange={(e) => setSortBy(e.target.value as SortField)}
118            className="px-3 py-1.5 rounded-[10px] border border-white/[0.08] bg-bg text-text text-[12px] outline-none cursor-pointer"
119            style={{ fontFamily: 'inherit' }}
120          >
121            <option value="modified">Newest first</option>
122            <option value="size">Largest first</option>
123            <option value="name">Name A-Z</option>
124          </select>
125        </div>
126  
127        {/* Category filters */}
128        <div className="flex gap-1.5 mb-4 flex-wrap">
129          <button
130            onClick={() => setFilterCategory(null)}
131            className={`px-3 py-1 rounded-full text-[11px] font-600 cursor-pointer transition-all border
132              ${!filterCategory
133                ? 'bg-accent-soft border-accent-bright/30 text-accent-bright'
134                : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.04]'}`}
135            style={{ fontFamily: 'inherit' }}
136          >
137            All
138          </button>
139          {categories.map((cat) => (
140            <button
141              key={cat}
142              onClick={() => setFilterCategory(filterCategory === cat ? null : cat)}
143              className={`px-3 py-1 rounded-full text-[11px] font-600 cursor-pointer transition-all border
144                ${filterCategory === cat
145                  ? 'bg-accent-soft border-accent-bright/30 text-accent-bright'
146                  : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.04]'}`}
147              style={{ fontFamily: 'inherit' }}
148            >
149              {CATEGORY_ICONS[cat] || ''} {CATEGORY_LABELS[cat] || cat}
150            </button>
151          ))}
152        </div>
153  
154        {/* Select all */}
155        {filtered.length > 0 && (
156          <div className="flex items-center gap-2 mb-3">
157            <button
158              onClick={toggleSelectAll}
159              className="text-[11px] text-accent-bright hover:underline cursor-pointer bg-transparent border-none"
160              style={{ fontFamily: 'inherit' }}
161            >
162              {selected.size === filtered.length ? 'Deselect all' : 'Select all'}
163            </button>
164            {selected.size > 0 && (
165              <span className="text-[11px] text-text-3">
166                {selected.size} selected
167              </span>
168            )}
169          </div>
170        )}
171  
172        {/* File grid */}
173        {filtered.length === 0 ? (
174          <div className="py-12 text-center text-[13px] text-text-3/60">
175            {files.length === 0 ? 'No uploaded files.' : 'No files match this filter.'}
176          </div>
177        ) : (
178          <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-[400px] overflow-y-auto pr-1">
179            {filtered.map((file) => (
180              <div
181                key={file.name}
182                onClick={() => toggleSelect(file.name)}
183                className={`relative p-3 rounded-[14px] border cursor-pointer transition-all
184                  ${selected.has(file.name)
185                    ? 'border-accent-bright/40 bg-accent-soft/30'
186                    : 'border-white/[0.06] bg-surface hover:border-white/[0.12]'}`}
187              >
188                {/* Checkbox */}
189                <div className={`absolute top-2 right-2 w-4 h-4 rounded-[5px] border transition-all flex items-center justify-center
190                  ${selected.has(file.name)
191                    ? 'border-accent-bright bg-accent-bright'
192                    : 'border-white/[0.15] bg-transparent'}`}
193                >
194                  {selected.has(file.name) && (
195                    <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
196                      <polyline points="20 6 9 17 4 12" />
197                    </svg>
198                  )}
199                </div>
200  
201                {/* Thumbnail / icon */}
202                <div className="w-full aspect-square rounded-[10px] bg-white/[0.03] mb-2 flex items-center justify-center overflow-hidden">
203                  {file.category === 'image' ? (
204                    // eslint-disable-next-line @next/next/no-img-element
205                    <img
206                      src={file.url}
207                      alt={file.name}
208                      className="w-full h-full object-cover rounded-[10px]"
209                      loading="lazy"
210                    />
211                  ) : (
212                    <span className="text-[28px]">{CATEGORY_ICONS[file.category] || CATEGORY_ICONS.other}</span>
213                  )}
214                </div>
215  
216                {/* Meta */}
217                <p className="text-[11px] font-600 text-text truncate" title={file.name}>{file.name}</p>
218                <p className="text-[10px] text-text-3/60 mt-0.5">
219                  {formatBytes(file.size)} &middot; {formatDate(file.modified)}
220                </p>
221              </div>
222            ))}
223          </div>
224        )}
225  
226        {/* Bulk delete footer */}
227        {selected.size > 0 && (
228          <div className="mt-4 pt-4 border-t border-white/[0.06] flex items-center justify-between">
229            <span className="text-[12px] text-text-3">
230              {selected.size} file{selected.size !== 1 ? 's' : ''} selected
231            </span>
232            <button
233              onClick={handleDeleteSelected}
234              className="px-4 py-2 rounded-[10px] bg-danger text-white text-[12px] font-600 cursor-pointer
235                hover:brightness-110 active:scale-[0.97] transition-all border-none"
236              style={{ fontFamily: 'inherit' }}
237            >
238              Delete Selected
239            </button>
240          </div>
241        )}
242  
243        <ConfirmDialog
244          open={!!confirmDelete}
245          title="Delete Files"
246          message={`Permanently delete ${confirmDelete?.length ?? 0} file${(confirmDelete?.length ?? 0) !== 1 ? 's' : ''}? This cannot be undone.`}
247          confirmLabel="Delete"
248          danger
249          onConfirm={executeDelete}
250          onCancel={() => setConfirmDelete(null)}
251        />
252      </div>
253    )
254  }