add more management capabilities

This commit is contained in:
Garret Patti
2026-04-11 18:33:03 -04:00
parent 1ca90184f5
commit 768c49ef00
16 changed files with 1420 additions and 55 deletions

View File

@@ -316,7 +316,39 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</button>
)}
{filteredEntries.map((entry) => (
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} />
<EntryTile
key={entry.name}
entry={entry}
onOpen={handleEntry}
onTag={handleTagEntry}
onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
.then(() => {
if (filtersActive) {
setRecursiveEntries((prev) => prev.filter((r) => r.name !== e.name))
} else {
setListing((prev) => prev ? { ...prev, entries: prev.entries.filter((r) => r.name !== e.name) } : prev)
}
})
}}
onRename={async (e, newName) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: rel, newName, itemType: 'mixed_file' }),
})
if (!res.ok) return false
// Refresh the listing
if (filtersActive) {
fetchRecursive()
} else {
loadPath(currentPath)
}
return true
}}
/>
))}
</div>
)}
@@ -392,11 +424,28 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)
}
function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void }) {
function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean> }) {
type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState<ImgState>(
entry.thumbnailUrl ? 'loading' : 'error'
)
const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [entryRenaming, setEntryRenaming] = useState(false)
const [entryRenameName, setEntryRenameName] = useState('')
const [entryRenameError, setEntryRenameError] = useState<string | null>(null)
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
// Reset image state when the entry changes (e.g. navigating to a new folder)
const prevUrl = useRef(entry.thumbnailUrl)
if (prevUrl.current !== entry.thumbnailUrl) {
@@ -497,6 +546,134 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
>
🏷
</button>
{/* Kebab menu — top-right, shown on hover */}
{(onDelete || onRename) && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{onRename && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setEntryRenameName(entry.name)
setEntryRenameError(null)
setEntryRenaming(true)
}}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename
</button>
)}
{onDelete && (
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: '#fca5a5' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Delete
</button>
)}
</div>
)}
</div>
)}
{/* Delete confirmation overlay */}
{confirming && (
<div
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
onClick={(e) => e.stopPropagation()}
>
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
<button
onClick={() => setConfirming(false)}
className="px-2 py-0.5 rounded transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={() => { setDeleting(true); onDelete!(entry) }}
disabled={deleting}
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{deleting ? '…' : 'Yes'}
</button>
</div>
)}
{/* Rename overlay */}
{entryRenaming && (
<div
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={entryRenameName}
onChange={(e) => setEntryRenameName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onRename) {
const trimmed = entryRenameName.trim()
if (!trimmed) return
setEntryRenameSaving(true)
setEntryRenameError(null)
onRename(entry, trimmed).then((ok) => {
if (ok) setEntryRenaming(false)
else setEntryRenameError('Name already exists')
}).finally(() => setEntryRenameSaving(false))
}
if (e.key === 'Escape') setEntryRenaming(false)
}}
className="w-full px-2 py-1 rounded text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<div className="flex gap-1 justify-end">
<button onClick={() => setEntryRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
<button
onClick={() => {
if (!onRename) return
const trimmed = entryRenameName.trim()
if (!trimmed) return
setEntryRenameSaving(true)
setEntryRenameError(null)
onRename(entry, trimmed).then((ok) => {
if (ok) setEntryRenaming(false)
else setEntryRenameError('Name already exists')
}).finally(() => setEntryRenameSaving(false))
}}
disabled={entryRenameSaving}
className="px-2 py-0.5 rounded disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{entryRenameSaving ? '…' : 'Rename'}
</button>
</div>
{entryRenameError && <p style={{ color: '#fca5a5' }}>{entryRenameError}</p>}
</div>
)}
</div>
)
}