'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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [modal, setModal] = useState(null) const [tagPanel, setTagPanel] = useState(null) const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) const [recursiveEntries, setRecursiveEntries] = useState([]) const [recursiveLoading, setRecursiveLoading] = useState(false) const [recursiveLoaded, setRecursiveLoaded] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollEntries, setDoomScrollEntries] = useState([]) const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false) const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = 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]) // Invalidate doom scroll entry cache when the user navigates to a different directory useEffect(() => { setDoomScrollEntries([]) setDoomScrollEntriesLoaded(false) setDoomScrollEntriesLoading(false) setDoomScrollLoading(false) }, [currentPath]) 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]) const fetchDoomScrollEntries = useCallback(() => { if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return setDoomScrollEntriesLoading(true) fetch( `/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true` ) .then((r) => r.json()) .then((data: DirectoryListing) => { setDoomScrollEntries(data.entries) setDoomScrollEntriesLoaded(true) }) .catch(() => {}) .finally(() => setDoomScrollEntriesLoading(false)) }, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading]) // 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 } // No filters: scope to current directory if (doomScrollEntriesLoaded) { setDoomScrollActive(true) return } setDoomScrollLoading(true) fetchDoomScrollEntries() } // Activate doom scroll once the appropriate listing finishes loading (when triggered by button) useEffect(() => { if (!doomScrollLoading) return const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded if (filtersDone || noFiltersDone) { setDoomScrollLoading(false) setDoomScrollActive(true) } }, [ doomScrollLoading, filtersActive, recursiveLoading, recursiveLoaded, doomScrollEntriesLoading, doomScrollEntriesLoaded, ]) // When filters are active, doom scroll uses filteredEntries (already filtered by search/tags). // When no filters, doom scroll uses files recursively under the current directory. const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries) .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 && ( setDoomScrollActive(false)} /> )}
{showFilters && (
)}
{/* Breadcrumb */} {(loading || recursiveLoading) && } {error && (
{error}
)} {!loading && !recursiveLoading && !error && (filtersActive || listing) && ( <> {filteredEntries.length === 0 ? (
{filtersActive ? 'No results found.' : 'This folder is empty.'}
) : (
{/* Up button — hidden during recursive search */} {!filtersActive && breadcrumbs.length > 0 && ( )} {filteredEntries.map((entry) => ( { 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') } } }} onDescribe={async (e) => { if (e.type === 'directory') { const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) const res = await fetch('/api/ai-tagging/describe-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 ?? 'Description generation failed') } } else { const itemKey = itemKeyFor(e) const res = await fetch('/api/ai-tagging/describe', { 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 ?? 'Description generation 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 }} /> ))}
)} )} {modal?.type === 'video' && ( { 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' && ( { 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 && (
{ if (e.target === e.currentTarget) setTagPanel(null) }} >

Tags

{tagPanel.entry.name}

{ setFilterRefreshKey((k) => k + 1); fetchAssignments() }} />
)}
) } function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise; onAiTag?: (e: FileEntry) => Promise; onExtractText?: (e: FileEntry) => Promise; onDescribe?: (e: FileEntry) => Promise }) { type ImgState = 'loading' | 'loaded' | 'error' const [imgState, setImgState] = useState( entry.thumbnailUrl ? 'loading' : 'error' ) const menuRef = useRef(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(null) const [entryRenameSaving, setEntryRenameSaving] = useState(false) const [aiTagging, setAiTagging] = useState(false) const [aiTagError, setAiTagError] = useState(null) const [textExtracting, setTextExtracting] = useState(false) const [textExtractError, setTextExtractError] = useState(null) const [describing, setDescribing] = useState(false) const [describeError, setDescribeError] = useState(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 (
onOpen(entry)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }} className="group relative flex flex-col rounded-xl border 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)' }} > {/* Inner wrapper — clips visual content to rounded corners */}
{/* Thumbnail image — hidden until loaded */} {entry.thumbnailUrl && ( // eslint-disable-next-line @next/next/no-img-element setImgState('loaded')} onError={() => setImgState('error')} /> )} {/* Skeleton pulse shown while image is loading */} {imgState === 'loading' && (
)} {/* Icon fallback — shown for dirs, other files, and failed thumbnails */} {!showThumbnail && imgState !== 'loading' && (
{icon}
)} {/* Bottom label — always shown */}
{entry.name}
{/* Video play badge — top-right overlay */} {isVideo && imgState === 'loaded' && (
)}
{/* Tag button — top-left, shown on hover */} {/* Kebab menu — bottom-right, shown on hover */} {(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
{menuOpen && (
{onAiTag && entry.mediaType === 'image' && ( )} {onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video') && ( )} {onDescribe && entry.type === 'directory' && ( )} {onExtractText && entry.mediaType === 'image' && ( )} {onExtractText && entry.type === 'directory' && ( )} {onRename && ( )} {onDelete && ( )}
)}
)} {/* AI tagging status overlay */} {(aiTagging || aiTagError) && (
e.stopPropagation()} > {aiTagError ?? 'AI Tagging…'} {aiTagError && ( )}
)} {/* Text extraction status overlay */} {(textExtracting || textExtractError) && (
e.stopPropagation()} > {textExtractError ?? 'Extracting text…'} {textExtractError && ( )}
)} {/* Description generation status overlay */} {(describing || describeError) && (
e.stopPropagation()} > {describeError ?? 'Generating description…'} {describeError && ( )}
)} {/* Delete confirmation overlay */} {confirming && (
e.stopPropagation()} >

Delete?

)} {/* Rename overlay */} {entryRenaming && (
e.stopPropagation()} > 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 />
{entryRenameError &&

{entryRenameError}

}
)}
) } function LoadingSkeleton() { return (
{Array.from({ length: 12 }).map((_, i) => (
))}
) }