This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/components/mixed/MixedView.tsx
2026-04-12 18:18:59 -04:00

869 lines
36 KiB
TypeScript

'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import type { DirectoryListing, FileEntry } from '@/types'
import VideoPlayerModal from './VideoPlayerModal'
import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
initialPath: string
}
type ModalState =
| { type: 'video'; url: string; name: string; itemKey: string; mediaIndex: number }
| { type: 'image'; url: string; name: string; itemKey: string; mediaIndex: number }
| null
type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, initialPath }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath)
const [listing, setListing] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>(null)
const [tagPanel, setTagPanel] = useState<TagPanelState>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true)
const [recursiveEntries, setRecursiveEntries] = useState<FileEntry[]>([])
const [recursiveLoading, setRecursiveLoading] = useState(false)
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => {
const next = new Set(prev)
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
return next
})
const loadPath = useCallback(
(path: string) => {
setLoading(true)
setError(null)
fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(path)}`
)
.then((r) => r.json())
.then((data: DirectoryListing) => {
setListing(data)
setCurrentPath(path)
setLoading(false)
})
.catch(() => {
setError('Failed to load directory')
setLoading(false)
})
},
[libraryId]
)
useEffect(() => {
loadPath(initialPath)
}, [loadPath, initialPath])
const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setAssignments)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchAssignments() }, [fetchAssignments])
const filtersActive = search !== '' || selectedTagIds.size > 0
const fetchRecursive = useCallback(() => {
if (recursiveLoaded || recursiveLoading) return
setRecursiveLoading(true)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`)
.then((r) => r.json())
.then((data: DirectoryListing) => {
setRecursiveEntries(data.entries)
setRecursiveLoaded(true)
})
.catch(() => {})
.finally(() => setRecursiveLoading(false))
}, [libraryId, recursiveLoaded, recursiveLoading])
// Fetch the full recursive listing the first time any filter becomes active
useEffect(() => {
if (!filtersActive) return
fetchRecursive()
}, [filtersActive, fetchRecursive])
const itemKeyFor = (entry: FileEntry) => {
// In recursive mode entry.name is already the full relative path from the library root
if (filtersActive) return `${libraryId}:mixed_file:${encodeURIComponent(entry.name)}`
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
return `${libraryId}:mixed_file:${encodeURIComponent(rel)}`
}
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
const filteredEntries = sourceEntries.filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
const entryTags = assignments[itemKeyFor(entry)] ?? []
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
}
return true
})
const mediaEntries = filteredEntries.filter(
(e) => e.mediaType === 'video' || e.mediaType === 'image'
)
const openMediaEntry = (entry: FileEntry, idx: number) => {
if (!entry.url) return
const itemKey = itemKeyFor(entry)
if (entry.mediaType === 'video') {
setModal({ type: 'video', url: entry.url, name: entry.name, itemKey, mediaIndex: idx })
} else if (entry.mediaType === 'image') {
setModal({ type: 'image', url: entry.url, name: entry.name, itemKey, mediaIndex: idx })
}
}
const navigateModal = (delta: -1 | 1) => {
if (!modal) return
const newIdx = Math.max(0, Math.min(mediaEntries.length - 1, modal.mediaIndex + delta))
if (newIdx === modal.mediaIndex) return
openMediaEntry(mediaEntries[newIdx], newIdx)
}
const handleEntry = (entry: FileEntry) => {
if (entry.type === 'directory') {
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
loadPath(newPath)
return
}
if (!entry.url) return
if (entry.mediaType === 'video' || entry.mediaType === 'image') {
const idx = mediaEntries.findIndex((e) => e.name === entry.name && e.url === entry.url)
openMediaEntry(entry, idx)
} else {
window.open(entry.url, '_blank')
}
}
const handleTagEntry = (entry: FileEntry) => {
setTagPanel({ entry, itemKey: itemKeyFor(entry) })
}
const navigateUp = () => {
const parts = currentPath.split('/').filter(Boolean)
parts.pop()
loadPath(parts.join('/'))
}
// Build breadcrumb segments
const breadcrumbs = currentPath
? currentPath.split('/').filter(Boolean)
: []
const handleDoomScroll = () => {
if (filtersActive) {
// filteredEntries already reflects the active filters; just ensure recursive data is loaded
if (recursiveLoaded) {
setDoomScrollActive(true)
return
}
// Recursive fetch was triggered by the filter becoming active; wait for it
setDoomScrollLoading(true)
fetchRecursive()
return
}
if (recursiveLoaded) {
setDoomScrollActive(true)
return
}
setDoomScrollLoading(true)
fetchRecursive()
}
// Activate doom scroll once the recursive listing finishes loading (when triggered by button)
useEffect(() => {
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) {
setDoomScrollLoading(false)
setDoomScrollActive(true)
}
}, [doomScrollLoading, recursiveLoading, recursiveLoaded])
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
// When no filters, doom scroll uses the full recursiveEntries.
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
return (
<>
{doomScrollActive && doomScrollItems.length > 0 && (
<DoomScrollView
items={doomScrollItems}
videoContext="mixed"
onClose={() => setDoomScrollActive(false)}
/>
)}
<div className="flex items-center gap-2 mb-4">
<button
onClick={() => setShowFilters((v) => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
<button
onClick={handleDoomScroll}
disabled={doomScrollLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--surface)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
onMouseEnter={(e) => { if (!doomScrollLoading) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)' }}
>
{doomScrollLoading ? 'Loading…' : 'Doom Scroll'}
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 md:items-start">
{showFilters && (
<div className="w-full md:w-52 md:flex-shrink-0">
<FilterPanel
libraryId={libraryId}
assignments={assignments}
search={search}
onSearchChange={setSearch}
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
/>
</div>
)}
<div className="flex-1 min-w-0">
{/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<button
onClick={() => loadPath('')}
className="transition-colors"
style={{ color: breadcrumbs.length === 0 ? 'var(--text-primary)' : 'var(--text-secondary)' }}
>
Root
</button>
{breadcrumbs.map((segment, i) => {
const isLast = i === breadcrumbs.length - 1
const pathTo = breadcrumbs.slice(0, i + 1).join('/')
return (
<span key={i} className="flex items-center gap-1">
<span style={{ color: 'var(--border)' }}>/</span>
<button
onClick={() => !isLast && loadPath(pathTo)}
className="transition-colors"
style={{
color: isLast ? 'var(--text-primary)' : 'var(--text-secondary)',
cursor: isLast ? 'default' : 'pointer',
}}
>
{segment}
</button>
</span>
)
})}
</nav>
{(loading || recursiveLoading) && <LoadingSkeleton />}
{error && (
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{error}
</div>
)}
{!loading && !recursiveLoading && !error && (filtersActive || listing) && (
<>
{filteredEntries.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
{filtersActive ? 'No results found.' : 'This folder is empty.'}
</div>
) : (
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{/* Up button — hidden during recursive search */}
{!filtersActive && breadcrumbs.length > 0 && (
<button
onClick={navigateUp}
className="flex flex-col items-center justify-center gap-2 rounded-xl border p-4 text-xs transition-colors"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', color: 'var(--text-secondary)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
>
<span className="text-2xl"></span>
<span>Up</span>
</button>
)}
{filteredEntries.map((entry) => (
<EntryTile
key={entry.name}
entry={entry}
onOpen={handleEntry}
onTag={handleTagEntry}
onAiTag={async (e) => {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
onExtractText={async (e) => {
if (e.type === 'directory') {
// Bulk extract for directory
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/extract-text-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, path: dirRel }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
}
} else {
// Single image extract
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
}
}
}}
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>
)}
</>
)}
{modal?.type === 'video' && (
<VideoPlayerModal
url={modal.url}
name={modal.name}
itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: modal.itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
} : undefined}
/>
)}
{modal?.type === 'image' && (
<ImageLightbox
url={modal.url}
name={modal.name}
itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: modal.itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
/>
)}
{/* Tag panel */}
{tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.entry.name}
</p>
</div>
<button
onClick={() => setTagPanel(null)}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label="Close"
>
</button>
</div>
<div className="px-5 py-4">
<TagSelector
itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
/>
</div>
</div>
</div>
)}
</div>
</div>
</>
)
}
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void> }) {
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)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [textExtracting, setTextExtracting] = useState(false)
const [textExtractError, setTextExtractError] = useState<string | null>(null)
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) {
prevUrl.current = entry.thumbnailUrl
if (entry.thumbnailUrl) setImgState('loading')
else setImgState('error')
}
const isDir = entry.type === 'directory'
const isVideo = entry.mediaType === 'video'
const showThumbnail = !isDir && imgState !== 'error' && entry.thumbnailUrl
// Icon shown when no thumbnail available or while loading
const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄'
return (
<div
role="button"
tabIndex={0}
onClick={() => onOpen(entry)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all cursor-pointer"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-1px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
{/* Thumbnail image — hidden until loaded */}
{entry.thumbnailUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={entry.thumbnailUrl}
alt=""
aria-hidden
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
style={{ opacity: imgState === 'loaded' ? 1 : 0 }}
onLoad={() => setImgState('loaded')}
onError={() => setImgState('error')}
/>
)}
{/* Skeleton pulse shown while image is loading */}
{imgState === 'loading' && (
<div
className="absolute inset-0 animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
/>
)}
{/* Icon fallback — shown for dirs, other files, and failed thumbnails */}
{!showThumbnail && imgState !== 'loading' && (
<div className="absolute inset-0 flex items-center justify-center text-3xl"
style={isVideo && imgState === 'error' ? { color: 'var(--accent)' } : undefined}>
{icon}
</div>
)}
{/* Bottom label — always shown */}
<div
className="absolute bottom-0 left-0 right-0 px-2 py-1.5"
style={{
background: showThumbnail
? 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, transparent 100%)'
: undefined,
}}
>
<span
className="block w-full truncate"
style={{ color: showThumbnail ? '#fff' : 'var(--text-primary)' }}
title={entry.name}
>
{entry.name}
</span>
</div>
{/* Video play badge — top-right overlay */}
{isVideo && imgState === 'loaded' && (
<div
className="absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
>
</div>
)}
{/* Tag button — top-left, shown on hover */}
<button
onClick={(e) => { e.stopPropagation(); onTag(entry) }}
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label={`Tag ${entry.name}`}
title="Tags"
>
🏷
</button>
{/* Kebab menu — top-right, shown on hover */}
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory')) && (
<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); setAiTagError(null) }}
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)' }}
>
{onAiTag && entry.mediaType === 'image' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setAiTagging(true)
setAiTagError(null)
onAiTag(entry)
.catch((err) => setAiTagError(err instanceof Error ? err.message : 'AI tagging failed'))
.finally(() => setAiTagging(false))
}}
disabled={aiTagging}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
AI Tag
</button>
)}
{onExtractText && entry.mediaType === 'image' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
disabled={textExtracting}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
🔍 Extract Text
</button>
)}
{onExtractText && entry.type === 'directory' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
disabled={textExtracting}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
🔍 Extract Text for Folder
</button>
)}
{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>
)}
{/* AI tagging status overlay */}
{(aiTagging || aiTagError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: aiTagError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: aiTagError ? '#fca5a5' : 'var(--text-secondary)' }}>
{aiTagError ?? 'AI Tagging…'}
</span>
{aiTagError && (
<button
onClick={() => setAiTagError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Text extraction status overlay */}
{(textExtracting || textExtractError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: textExtractError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: textExtractError ? '#fca5a5' : 'var(--text-secondary)' }}>
{textExtractError ?? 'Extracting text…'}
</span>
{textExtractError && (
<button
onClick={() => setTextExtractError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</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>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="rounded-xl animate-pulse"
style={{ backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
/>
))}
</div>
)
}