feat(SearchOverlay): add onOpenFolder prop and integrate folder opening functionality

This commit is contained in:
Jaime Idolpx 2026-06-14 01:13:36 -04:00
parent 86de697569
commit 451c9f665b
4 changed files with 141 additions and 109 deletions

View File

@ -318,6 +318,12 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
config={config} config={config}
setConfig={setConfig} setConfig={setConfig}
onClose={() => setShowSearch(false)} onClose={() => setShowSearch(false)}
onOpenFolder={(path) => {
setFileManagerInitialPath(path);
setFileManagerReturnPage('status');
setShowSearch(false);
setCurrentPage('file-manager');
}}
/> />
</Suspense> </Suspense>
)} )}

View File

@ -128,76 +128,7 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin
// EntryIcon is imported from MediaEntry. // EntryIcon is imported from MediaEntry.
// ─── MarqueeText ────────────────────────────────────────────────────────────── import { MarqueeText } from './ui/marquee-text';
// Scrolls text left→right when it overflows its container, then back — ping-pong.
// Injects a unique per-instance @keyframes rule with the exact pixel offset so
// no CSS variables are needed (more reliable inside Radix portals).
let _mmSeq = 0;
function MarqueeText({ children, className }: { children?: string; className?: string }) {
const wrapRef = useRef<HTMLSpanElement>(null);
const idRef = useRef(`mm${++_mmSeq}`);
const [animCSS, setAnimCSS] = useState('');
useEffect(() => {
let cancelled = false;
const id = idRef.current;
const wrap = wrapRef.current;
if (!wrap) return;
const measure = () => {
if (cancelled) return;
const ov = wrap.scrollWidth - wrap.clientWidth;
if (ov <= 0) { setAnimCSS(''); return; }
// Inject / update the keyframe with the exact measured pixel offset.
let el = document.getElementById(`kf-${id}`) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = `kf-${id}`;
document.head.appendChild(el);
}
el.textContent = `@keyframes ${id}{0%,12%{transform:translateX(0)}88%,100%{transform:translateX(-${ov}px)}}`;
const secs = (Math.max(2000, ov * 20) / 1000).toFixed(2);
setAnimCSS(`${id} ${secs}s ease-in-out 0.8s infinite alternate`);
};
// Two nested RAFs ensure the portal element has a stable layout before we
// measure (one RAF fires before browser layout; two guarantees post-layout).
const r1 = requestAnimationFrame(() => {
const r2 = requestAnimationFrame(measure);
return () => cancelAnimationFrame(r2);
});
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => {
cancelled = true;
cancelAnimationFrame(r1);
ro.disconnect();
document.getElementById(`kf-${id}`)?.remove();
setAnimCSS('');
};
}, [children]);
return (
// overflow:hidden clips the scrolling text; minWidth:0 lets the element
// shrink inside grid/flex parents without forcing them to expand to the
// full text width (white-space:nowrap lives only on the inner span).
<span
ref={wrapRef}
style={{ overflow: 'hidden', display: 'block', minWidth: 0 }}
className={className}
>
<span style={{ display: 'inline-block', whiteSpace: 'nowrap', animation: animCSS || undefined }}>
{children}
</span>
</span>
);
}
// ─── ActionsModal ───────────────────────────────────────────────────────────── // ─── ActionsModal ─────────────────────────────────────────────────────────────

View File

@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { X, Search, Loader2, RefreshCw, FolderSearch, File, Folder, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive } from 'lucide-react'; import { X, Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { humanFileSize } from '../webdav'; import { humanFileSize, splitPath } from '../webdav';
import type { EntryInfo } from '../webdav';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { MediaEntry } from './MediaEntry';
import { MarqueeText } from './ui/marquee-text';
import { import {
openLocateDb, openLocateDb,
searchLocate, searchLocate,
@ -19,6 +22,7 @@ interface SearchOverlayProps {
config: any; config: any;
setConfig: (config: any) => void; setConfig: (config: any) => void;
onClose: () => void; onClose: () => void;
onOpenFolder: (path: string) => void;
} }
interface SearchResult { interface SearchResult {
@ -101,6 +105,10 @@ function entryToResult(e: LocateEntry): SearchResult {
}; };
} }
function resultToEntry(r: SearchResult): EntryInfo {
return { name: r.name, path: r.path, type: r.isDir ? 'folder' : 'file', size: r.size, lastModified: null, contentType: null };
}
function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) { function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) {
if (isDir) return <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-mono">DIR</span>; if (isDir) return <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-mono">DIR</span>;
const ext = type.toLowerCase(); const ext = type.toLowerCase();
@ -144,7 +152,7 @@ function FilterChips({
); );
} }
export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) { export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder }: SearchOverlayProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
@ -153,6 +161,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null); const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
const [searchError, setSearchError] = useState<string | null>(null); const [searchError, setSearchError] = useState<string | null>(null);
const [dbBytes, setDbBytes] = useState<number | null>(null); const [dbBytes, setDbBytes] = useState<number | null>(null);
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
@ -292,16 +301,14 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 backdrop-blur-md bg-black/40" className="fixed inset-0 z-50"
onClick={onClose}
> >
<motion.div <motion.div
initial={{ y: '100%' }} initial={{ y: '100%' }}
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }} transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="fixed inset-x-0 bottom-0 top-12 bg-white rounded-t-2xl shadow-2xl flex flex-col overflow-hidden" className="fixed inset-0 bg-white/95 backdrop-blur-sm flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="flex-shrink-0 px-4 pt-4 pb-3 border-b border-neutral-100"> <div className="flex-shrink-0 px-4 pt-4 pb-3 border-b border-neutral-100">
@ -430,45 +437,33 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
{/* Results */} {/* Results */}
{!searchError && hasSearched && ( {!searchError && hasSearched && (
<div className="p-3"> <div>
{visibleResults.length > 0 ? ( {visibleResults.length > 0 ? (
<> <>
<p className="text-xs text-neutral-400 px-1 mb-2 flex items-center gap-1.5"> <p className="text-xs text-neutral-400 px-4 py-2 flex items-center gap-1.5 border-b border-neutral-100">
{isSearching && <Loader2 className="w-3 h-3 animate-spin" />} {isSearching && <Loader2 className="w-3 h-3 animate-spin" />}
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''} {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
</p> </p>
<div className="space-y-1.5"> <div>
{visibleResults.map((result) => ( {visibleResults.map((result) => (
<div <MediaEntry
key={result.path} key={result.path}
className="bg-neutral-50 border border-neutral-200 rounded-xl px-3 py-2.5 flex items-center gap-3 hover:border-blue-200 hover:bg-blue-50 transition-colors" entry={resultToEntry(result)}
> onPrimaryClick={() => setMountEntry(result)}
<div className="w-8 h-8 rounded-lg bg-white border border-neutral-200 flex items-center justify-center flex-shrink-0 shadow-sm"> onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }}
{result.isDir nameSlot={
? <Folder className="w-4 h-4 text-amber-500" /> <>
: <File className="w-4 h-4 text-blue-500" /> <div className="flex items-center gap-1.5 flex-wrap">
} <span className="text-neutral-900 text-sm">{result.name}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="text-sm font-medium text-neutral-800 truncate">{result.name}</span>
<TypeBadge type={result.type} isDir={result.isDir} /> <TypeBadge type={result.type} isDir={result.isDir} />
{result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>} {result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>}
{result.video && <span className="text-xs px-1.5 py-0.5 rounded bg-teal-100 text-teal-700 font-mono">{result.video}</span>} {result.video && <span className="text-xs px-1.5 py-0.5 rounded bg-teal-100 text-teal-700 font-mono">{result.video}</span>}
{result.language && <span className="text-xs px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-mono">{result.language}</span>} {result.language && <span className="text-xs px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-mono">{result.language}</span>}
</div> </div>
<p className="text-xs text-neutral-400 truncate mt-0.5">{result.path}</p> <div className="text-xs text-neutral-400 truncate">{result.path}</div>
</div> </>
{!result.isDir && ( }
<span className="text-xs text-neutral-400 flex-shrink-0">{result.sizeText}</span> />
)}
<button
onClick={() => setMountEntry(result)}
className="flex-shrink-0 px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-700 transition-colors"
>
Mount
</button>
</div>
))} ))}
</div> </div>
</> </>
@ -507,6 +502,40 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
</motion.div> </motion.div>
</motion.div> </motion.div>
{/* Actions dialog */}
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
<DialogContent className="max-w-sm">
<DialogHeader className="overflow-hidden min-w-0">
<DialogTitle className="sr-only">{actionEntry?.name}</DialogTitle>
<p className="text-lg font-semibold leading-none pr-6 overflow-hidden min-w-0">
<MarqueeText>{actionEntry?.name}</MarqueeText>
</p>
<DialogDescription>{actionEntry?.isDir ? 'Folder' : actionEntry?.sizeText}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{!actionEntry?.isDir && (
<button
onClick={() => { setMountEntry(actionEntry); setActionEntry(null); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
</button>
)}
<button
onClick={() => {
const folder = actionEntry ? splitPath(actionEntry.path).parent : '/';
setActionEntry(null);
onClose();
onOpenFolder(folder);
}}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<FolderOpen className="w-4 h-4 text-blue-600" /> <span>Open containing folder</span>
</button>
</div>
</DialogContent>
</Dialog>
{/* Mount dialog — same pattern as MediaManager */} {/* Mount dialog — same pattern as MediaManager */}
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}> <Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]"> <DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">

View File

@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from 'react';
let _seq = 0;
/**
* Scrolls text leftright when it overflows its container (ping-pong).
* Injects a unique per-instance @keyframes rule with the exact pixel offset
* so no CSS variables are needed reliable inside Radix portals.
*/
export function MarqueeText({ children, className }: { children?: string; className?: string }) {
const wrapRef = useRef<HTMLSpanElement>(null);
const idRef = useRef(`mm${++_seq}`);
const [animCSS, setAnimCSS] = useState('');
useEffect(() => {
let cancelled = false;
const id = idRef.current;
const wrap = wrapRef.current;
if (!wrap) return;
const measure = () => {
if (cancelled) return;
const ov = wrap.scrollWidth - wrap.clientWidth;
if (ov <= 0) { setAnimCSS(''); return; }
let el = document.getElementById(`kf-${id}`) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = `kf-${id}`;
document.head.appendChild(el);
}
el.textContent = `@keyframes ${id}{0%,12%{transform:translateX(0)}88%,100%{transform:translateX(-${ov}px)}}`;
const secs = (Math.max(2000, ov * 20) / 1000).toFixed(2);
setAnimCSS(`${id} ${secs}s ease-in-out 0.8s infinite alternate`);
};
const r1 = requestAnimationFrame(() => {
const r2 = requestAnimationFrame(measure);
return () => cancelAnimationFrame(r2);
});
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => {
cancelled = true;
cancelAnimationFrame(r1);
ro.disconnect();
document.getElementById(`kf-${id}`)?.remove();
setAnimCSS('');
};
}, [children]);
return (
<span
ref={wrapRef}
style={{ overflow: 'hidden', display: 'block', minWidth: 0 }}
className={className}
>
<span style={{ display: 'inline-block', whiteSpace: 'nowrap', animation: animCSS || undefined }}>
{children}
</span>
</span>
);
}