add more management capabilities
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user