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' : ''} · {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)} · {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 }