From 1d2690efa4659566640e10dbb9a744ede5da5bbd Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Tue, 9 Jun 2026 15:51:41 -0400 Subject: [PATCH] Refactor MediaBrowser and MediaManager components; extract EntryIcon and MediaEntry for better code organization - Removed unused imports and constants from MediaBrowser. - Introduced MediaEntry component to encapsulate entry rendering logic. - Simplified MediaBrowser's entry handling and navigation logic. - Updated MediaManager to utilize MediaEntry for rendering entries. - Refactored StatusPage to use MediaEntry for print file listing. --- src/app/components/MediaBrowser.tsx | 396 +++++----------------------- src/app/components/MediaEntry.tsx | 103 ++++++++ src/app/components/MediaManager.tsx | 149 +++-------- src/app/components/StatusPage.tsx | 38 +-- 4 files changed, 225 insertions(+), 461 deletions(-) create mode 100644 src/app/components/MediaEntry.tsx diff --git a/src/app/components/MediaBrowser.tsx b/src/app/components/MediaBrowser.tsx index ea342e3..79eb251 100644 --- a/src/app/components/MediaBrowser.tsx +++ b/src/app/components/MediaBrowser.tsx @@ -1,28 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import { ArrowLeft, - BookOpen, - Braces, - CassetteTape, Check, ChevronRight, - Code2, - Cpu, - Disc, - Save, - File, - FileText, Folder, FolderPlus, - HardDrive, Home, - Image as ImageIcon, Loader2, - MoreVertical, - Music, - Package, RefreshCw, - SlidersHorizontal, Trash2, Upload, } from 'lucide-react'; @@ -46,39 +31,7 @@ import { DialogTitle, DialogDescription, } from './ui/dialog'; - -const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst']); -const MD_EXTS = new Set(['md','markdown']); -const JSON_EXTS = new Set(['json']); -const XML_EXTS = new Set(['xml','svg','html','htm','rss','atom','xsl']); -const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','bmp','webp']); -const AUDIO_EXTS = new Set(['sid','psid','mus','vgm']); -const ROM_EXTS = new Set(['bin','rom','crt']); -const TAPE_EXTS = new Set(['tap','t64','tcrt']); -const DISK_EXTS = new Set(['d41','d64','d71','d80','d81','d82','d8b','dfi','g64','g71','g81','p64','p71','p81','nib']); -const DISC_EXTS = new Set(['iso','img','cue']); -const HD_EXTS = new Set(['d1m','d2m','d4m','d90','dhd','hdd']); -const ARCHIVE_EXTS = new Set(['zip','7z','tar','gz','bz2','xz','rar','arj','lzh','ace','z','lha','cab','lbr','arc','ark','lnx']); -const CONFIG_EXTS = new Set(['config']); - -function EntryIcon({ entry }: { entry: EntryInfo }) { - if (entry.type === 'folder') return ; - const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; - if (IMAGE_EXTS.has(ext)) return ; - if (DISK_EXTS.has(ext)) return ; - if (HD_EXTS.has(ext)) return ; - if (DISC_EXTS.has(ext)) return ; - if (TAPE_EXTS.has(ext)) return ; - if (ROM_EXTS.has(ext)) return ; - if (AUDIO_EXTS.has(ext)) return ; - if (ARCHIVE_EXTS.has(ext)) return ; - if (CONFIG_EXTS.has(ext)) return ; - if (JSON_EXTS.has(ext)) return ; - if (XML_EXTS.has(ext)) return ; - if (MD_EXTS.has(ext)) return ; - if (TEXT_EXTS.has(ext)) return ; - return ; -} +import { MediaEntry } from './MediaEntry'; interface MediaBrowserProps { currentPath: string; @@ -87,8 +40,6 @@ interface MediaBrowserProps { } export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBrowserProps) { - // Resolve the initial path: if `currentPath` is itself a file, jump - // to its parent so we never try to list a file as if it were a folder. const [path, setPath] = useState(null); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); @@ -98,130 +49,63 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr const [actionEntry, setActionEntry] = useState(null); const fileInputRef = useRef(null); - // Resolve the initial path on mount. useEffect(() => { const initial = normalizePath(currentPath || '/'); - if (initial === '/') { - setPath('/'); - return; - } + if (initial === '/') { setPath('/'); return; } let cancelled = false; stat(initial) - .then((info) => { + .then(info => { if (cancelled) return; - if (info && info.type === 'file') { - setPath(splitPath(info.path).parent); - } else if (info && info.type === 'folder') { - setPath(info.path); - } else { - setPath(splitPath(initial).parent); - } + if (info?.type === 'file') setPath(splitPath(info.path).parent); + else if (info?.type === 'folder') setPath(info.path); + else setPath(splitPath(initial).parent); }) - .catch(() => { - if (cancelled) return; - setPath(splitPath(initial).parent); - }); - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + .catch(() => { if (!cancelled) setPath(splitPath(initial).parent); }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const load = async (p: string) => { - setLoading(true); - setError(null); - try { - const items = await listDirectory(p); - setEntries(items); - } catch (e: any) { - const msg = (e && e.message) || 'Failed to load directory'; - setError(msg); - setEntries([]); - } finally { - setLoading(false); - } + setLoading(true); setError(null); + try { setEntries(await listDirectory(p)); } + catch (e: any) { setError(e?.message ?? 'Failed to load directory'); setEntries([]); } + finally { setLoading(false); } }; - useEffect(() => { - if (path === null) return; - void load(path); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [path]); + useEffect(() => { if (path !== null) void load(path); }, [path]); - const navigateUp = () => { - if (path === '/' || path === null) return; - setPath(splitPath(path).parent); - }; - - const navigateToFolder = (folderName: string) => { - if (path === null) return; - setPath(joinPath(path, folderName)); - }; - - const selectEntry = (entry: EntryInfo) => { - if (entry.type !== 'file') return; - onSelect(entry.path); - onClose(); - }; - - const selectCurrentFolder = () => { - if (path === null) return; - onSelect(path); - onClose(); - }; - - const refresh = () => { - if (path !== null) void load(path); - }; + const navigateUp = () => { if (path && path !== '/') setPath(splitPath(path).parent); }; + const navigateToFolder = (name: string) => { if (path) setPath(joinPath(path, name)); }; + const selectEntry = (entry: EntryInfo) => { if (entry.type !== 'file') return; onSelect(entry.path); onClose(); }; + const selectCurrentFolder = () => { if (path) { onSelect(path); onClose(); } }; const handleCreateFolder = async () => { const name = newFolderName.trim(); - if (!name || path === null) return; + if (!name || !path) return; try { await createFolder(joinPath(path, name), true); - setNewFolderName(''); - setShowNewFolder(false); + setNewFolderName(''); setShowNewFolder(false); toast.success(`Created folder "${name}"`); void load(path); - } catch (e: any) { - toast.error(`Failed to create folder: ${e?.message || e}`); - } + } catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); } }; const handleDelete = async (entry: EntryInfo) => { - const ok = window.confirm( - entry.type === 'folder' - ? `Delete folder "${entry.name}" and all its contents?` - : `Delete file "${entry.name}"?`, - ); - if (!ok) return; + if (!window.confirm(entry.type === 'folder' ? `Delete folder "${entry.name}" and all its contents?` : `Delete file "${entry.name}"?`)) return; setActionEntry(null); - try { - await deletePath(entry.path); - toast.success(`Deleted ${entry.name}`); - if (path !== null) void load(path); - } catch (e: any) { - toast.error(`Failed to delete: ${e?.message || e}`); - } + try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); if (path) void load(path); } + catch (e: any) { toast.error(`Failed to delete: ${e?.message ?? e}`); } }; const handleUpload = async (file: File) => { - if (path === null) return; - const target = joinPath(path, file.name); - try { - const buf = await file.arrayBuffer(); - await putFileContents(target, buf); - toast.success(`Uploaded ${file.name}`); - void load(path); - } catch (e: any) { - toast.error(`Failed to upload ${file.name}: ${e?.message || e}`); - } + if (!path) return; + try { await putFileContents(joinPath(path, file.name), await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path); } + catch (e: any) { toast.error(`Failed to upload ${file.name}: ${e?.message ?? e}`); } }; const onPickFiles = (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files) return; - Array.from(files).forEach((f) => void handleUpload(f)); + if (!e.target.files) return; + Array.from(e.target.files).forEach(f => void handleUpload(f)); e.target.value = ''; }; @@ -229,96 +113,34 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr return (
-
e.stopPropagation()} - > +
e.stopPropagation()}>

Browse Files

- - - - - + + + + +
{showNewFolder && (
- setNewFolderName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void handleCreateFolder(); - if (e.key === 'Escape') { - setShowNewFolder(false); - setNewFolderName(''); - } - }} - placeholder="New folder name" - className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" - autoFocus - /> - + setNewFolderName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleCreateFolder(); if (e.key === 'Escape') { setShowNewFolder(false); setNewFolderName(''); } }} + placeholder="New folder name" className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" autoFocus /> +
)}
- - {pathParts.map((part, index) => ( -
+ + {pathParts.map((part, i) => ( +
- +
))}
@@ -327,155 +149,77 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
{path === null && (
- - Resolving location… + Resolving location…
)} - {loading && (
- - Loading… + Loading…
)} - {!loading && error && (
Failed to load directory
{error}
-
)} - {!loading && !error && path !== null && ( <> {path !== '/' && ( - )} - - {entries.map((entry) => ( -
( + - - -
+ entry={entry} + onPrimaryClick={() => entry.type === 'folder' ? navigateToFolder(entry.name) : selectEntry(entry)} + onActionsClick={e => { e.stopPropagation(); setActionEntry(entry); }} + /> ))} - - {entries.length === 0 && ( -
- Empty folder -
- )} + {entries.length === 0 &&
Empty folder
} )}
-
- {/* Action menu modal */} - !open && setActionEntry(null)} - > + !open && setActionEntry(null)}> {actionEntry?.name} - {actionEntry?.type === 'folder' ? 'Folder' : 'File'} - {actionEntry?.type === 'file' && actionEntry.size > 0 - ? ` · ${humanFileSize(actionEntry.size)}` - : ''} + {actionEntry?.type === 'folder' ? 'Folder' : `File · ${humanFileSize(actionEntry?.size ?? 0)}`}
{actionEntry?.type === 'file' && ( - )} {actionEntry?.type === 'folder' && ( - )} -
diff --git a/src/app/components/MediaEntry.tsx b/src/app/components/MediaEntry.tsx new file mode 100644 index 0000000..35339c4 --- /dev/null +++ b/src/app/components/MediaEntry.tsx @@ -0,0 +1,103 @@ +import { + BookOpen, + Braces, + CassetteTape, + ChevronRight, + Code2, + Cpu, + Disc, + File, + FileText, + FileType, + Folder, + HardDrive, + Image as ImageIcon, + MoreVertical, + Music, + Package, + Save, + SlidersHorizontal, + Terminal, +} from 'lucide-react'; +import { humanFileSize, type EntryInfo } from '../webdav'; + +// ─── Extension sets ─────────────────────────────────────────────────────────── + +export const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']); +export const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']); +export const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']); +export const MD_EXTS = new Set(['md', 'markdown']); +export const JSON_EXTS = new Set(['json', 'webmanifest']); +export const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']); +export const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']); +export const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', 'mus', 'vgm']); +export const ROM_EXTS = new Set(['bin', 'rom', 'crt']); +export const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']); +export const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']); +export const DISC_EXTS = new Set(['iso', 'img', 'cue']); +export const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']); +export const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']); +export const CONFIG_EXTS = new Set(['config']); + +// ─── EntryIcon ──────────────────────────────────────────────────────────────── + +export function EntryIcon({ entry }: { entry: EntryInfo }) { + if (entry.type === 'folder') return ; + const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; + if (IMAGE_EXTS.has(ext)) return ; + if (DISK_EXTS.has(ext)) return ; + if (HD_EXTS.has(ext)) return ; + if (DISC_EXTS.has(ext)) return ; + if (TAPE_EXTS.has(ext)) return ; + if (ROM_EXTS.has(ext)) return ; + if (AUDIO_EXTS.has(ext)) return ; + if (ARCHIVE_EXTS.has(ext)) return ; + if (CONFIG_EXTS.has(ext)) return ; + if (JSON_EXTS.has(ext)) return ; + if (XML_EXTS.has(ext)) return ; + if (MD_EXTS.has(ext)) return ; + if (DOC_EXTS.has(ext)) return ; + if (CODE_EXTS.has(ext)) return ; + if (TEXT_EXTS.has(ext)) return ; + return ; +} + +// ─── MediaEntry ─────────────────────────────────────────────────────────────── + +export interface MediaEntryProps { + entry: EntryInfo; + onPrimaryClick: () => void; + onActionsClick: (e: React.MouseEvent) => void; + /** Optional leading slot — e.g. a checkbox in MediaManager. */ + leftSlot?: React.ReactNode; + /** Replaces the filename text — e.g. an inline rename input. */ + nameSlot?: React.ReactNode; + selected?: boolean; + className?: string; +} + +export function MediaEntry({ + entry, onPrimaryClick, onActionsClick, leftSlot, nameSlot, selected, className, +}: MediaEntryProps) { + return ( +
+ {leftSlot} + + +
+ ); +} diff --git a/src/app/components/MediaManager.tsx b/src/app/components/MediaManager.tsx index da80131..8f9556b 100644 --- a/src/app/components/MediaManager.tsx +++ b/src/app/components/MediaManager.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AlignLeft, ArrowLeft, + Book, BookOpen, Braces, - CassetteTape, Check, CheckSquare, ChevronLeft, @@ -12,14 +12,9 @@ import { ClipboardPaste, Code2, Copy, - Cpu, - Disc, Download, Eye, - File, FilePlus, - FileText, - FileType, Folder, FolderPlus, HardDrive, @@ -29,19 +24,17 @@ import { Loader2, MoreVertical, Move, - Music, - Package, - SlidersHorizontal, Pencil, RefreshCw, Save, Search, + SlidersHorizontal, Terminal, Trash2, Upload, X, - Book, } from 'lucide-react'; +import { MediaEntry, TEXT_EXTS, DOC_EXTS, CODE_EXTS, MD_EXTS, JSON_EXTS, XML_EXTS, IMAGE_EXTS, CONFIG_EXTS } from './MediaEntry'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import ReactMarkdown from 'react-markdown'; @@ -82,23 +75,7 @@ type SortKey = 'name' | 'size' | 'date'; type Clipboard = { op: 'copy' | 'move'; paths: string[] }; type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code' | 'doc'; -// ─── Extension sets ────────────────────────────────────────────────────────── - -const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']); -const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']); -const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']); -const MD_EXTS = new Set(['md', 'markdown']); -const JSON_EXTS = new Set(['json', 'webmanifest']); -const XML_EXTS = new Set(['xml', 'html', 'htm', 'rss', 'atom', 'xsl']); -const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']); -const AUDIO_EXTS = new Set(['sid', 'psid', 'rsid', 'mus', 'vgm']); -const ROM_EXTS = new Set(['bin', 'rom', 'crt']); -const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']); -const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']); -const DISC_EXTS = new Set(['iso', 'img', 'cue']); -const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd', 'bbt', 'd8b', 'dfi']); -const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx']); -const CONFIG_EXTS = new Set(['config']); +// Extension sets are imported from MediaEntry. function defaultViewMode(entry: EntryInfo): ViewMode { const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; @@ -266,27 +243,7 @@ function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) = ); } -// ─── Entry icon ─────────────────────────────────────────────────────────────── - -function EntryIcon({ entry }: { entry: EntryInfo }) { - if (entry.type === 'folder') return ; - const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; - if (IMAGE_EXTS.has(ext)) return ; - if (DISK_EXTS.has(ext)) return ; - if (HD_EXTS.has(ext)) return ; - if (DISC_EXTS.has(ext)) return ; - if (TAPE_EXTS.has(ext)) return ; - if (ROM_EXTS.has(ext)) return ; - if (AUDIO_EXTS.has(ext)) return ; - if (ARCHIVE_EXTS.has(ext)) return ; - if (CONFIG_EXTS.has(ext)) return ; - if (JSON_EXTS.has(ext)) return ; - if (XML_EXTS.has(ext)) return ; - if (MD_EXTS.has(ext)) return ; - if (DOC_EXTS.has(ext)) return ; - if (TEXT_EXTS.has(ext)) return ; - return ; -} +// EntryIcon is imported from MediaEntry. // ─── ActionsModal ───────────────────────────────────────────────────────────── @@ -1179,65 +1136,45 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi )} {visible.map(entry => ( -
- toggleSelect(entry.path)} - onClick={e => e.stopPropagation()} - className="w-4 h-4 flex-shrink-0" - /> - - -
+ entry={entry} + selected={selected.has(entry.path)} + onPrimaryClick={() => { + if (renameEntry !== null) return; + if (entry.type === 'folder') navigateTo(joinPath(path, entry.name)); + else setMountEntry(entry); + }} + onActionsClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }} + leftSlot={ + toggleSelect(entry.path)} + onClick={e => e.stopPropagation()} + className="w-4 h-4 flex-shrink-0" + /> + } + nameSlot={renameEntry?.path === entry.path ? ( + setRenameName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void commitRename(); + if (e.key === 'Escape') setRenameEntry(null); + }} + onBlur={() => void commitRename()} + onClick={e => e.stopPropagation()} + onFocus={e => { + const dot = renameName.lastIndexOf('.'); + e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length); + }} + className="w-full px-1 py-0 border border-blue-400 rounded text-sm" + autoFocus + /> + ) : undefined} + /> ))} {visible.length === 0 && entries.length === 0 && ( diff --git a/src/app/components/StatusPage.tsx b/src/app/components/StatusPage.tsx index 523bd08..dede96f 100644 --- a/src/app/components/StatusPage.tsx +++ b/src/app/components/StatusPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; -import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, FileText, Power, Computer, MoreVertical, Download, Trash2, Eye, File as FileIcon } from 'lucide-react'; +import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, Power, Computer, Download, Trash2, Eye } from 'lucide-react'; import { listDirectory, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, type EntryInfo } from '../webdav'; import { toast } from 'sonner'; +import { MediaEntry } from './MediaEntry'; import { useWs } from '../ws'; import DeviceDetailOverlay from './DeviceDetailOverlay'; import MediaSet from './MediaSet'; @@ -118,12 +119,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) { } catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); } }; - const printFileIcon = (entry: EntryInfo) => { - const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; - if (['txt', 'log', 'prn', 'lst'].includes(ext)) - return ; - return ; - }; return (
@@ -301,28 +296,13 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
No print files found in /sd/.print
) : ( printFiles.map(entry => ( -
- - -
+ window.open(printFileUrl(entry), '_blank')} + onActionsClick={e => { e.stopPropagation(); setPrintActionEntry(entry); }} + className="last:border-b-0" + /> )) )}