From 451c9f665b72cabf25e30ff7e2412b8d594c7c26 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Sun, 14 Jun 2026 01:13:36 -0400 Subject: [PATCH] feat(SearchOverlay): add onOpenFolder prop and integrate folder opening functionality --- src/app/App.tsx | 6 ++ src/app/components/MediaManager.tsx | 71 +--------------- src/app/components/SearchOverlay.tsx | 107 ++++++++++++++++--------- src/app/components/ui/marquee-text.tsx | 66 +++++++++++++++ 4 files changed, 141 insertions(+), 109 deletions(-) create mode 100644 src/app/components/ui/marquee-text.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index a945584..eda1d23 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -318,6 +318,12 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) { config={config} setConfig={setConfig} onClose={() => setShowSearch(false)} + onOpenFolder={(path) => { + setFileManagerInitialPath(path); + setFileManagerReturnPage('status'); + setShowSearch(false); + setCurrentPage('file-manager'); + }} /> )} diff --git a/src/app/components/MediaManager.tsx b/src/app/components/MediaManager.tsx index 77fdd66..97e2c22 100644 --- a/src/app/components/MediaManager.tsx +++ b/src/app/components/MediaManager.tsx @@ -128,76 +128,7 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin // EntryIcon is imported from MediaEntry. -// ─── MarqueeText ────────────────────────────────────────────────────────────── -// 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(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). - - - {children} - - - ); -} +import { MarqueeText } from './ui/marquee-text'; // ─── ActionsModal ───────────────────────────────────────────────────────────── diff --git a/src/app/components/SearchOverlay.tsx b/src/app/components/SearchOverlay.tsx index bb7319d..7de1c19 100644 --- a/src/app/components/SearchOverlay.tsx +++ b/src/app/components/SearchOverlay.tsx @@ -1,10 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; 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 { 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 { MediaEntry } from './MediaEntry'; +import { MarqueeText } from './ui/marquee-text'; import { openLocateDb, searchLocate, @@ -19,6 +22,7 @@ interface SearchOverlayProps { config: any; setConfig: (config: any) => void; onClose: () => void; + onOpenFolder: (path: string) => void; } 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 }) { if (isDir) return DIR; 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 [isSearching, setIsSearching] = useState(false); const [isScanning, setIsScanning] = useState(false); @@ -153,6 +161,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver const [results, setResults] = useState([]); const [hasSearched, setHasSearched] = useState(false); const [mountEntry, setMountEntry] = useState(null); + const [actionEntry, setActionEntry] = useState(null); const [searchError, setSearchError] = useState(null); const [dbBytes, setDbBytes] = useState(null); const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); @@ -292,16 +301,14 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 z-50 backdrop-blur-md bg-black/40" - onClick={onClose} + className="fixed inset-0 z-50" > e.stopPropagation()} + className="fixed inset-0 bg-white/95 backdrop-blur-sm flex flex-col overflow-hidden" > {/* Header */}
@@ -430,45 +437,33 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver {/* Results */} {!searchError && hasSearched && ( -
+
{visibleResults.length > 0 ? ( <> -

+

{isSearching && } {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}

-
+
{visibleResults.map((result) => ( -
-
- {result.isDir - ? - : - } -
-
-
- {result.name} - - {result.system && {result.system}} - {result.video && {result.video}} - {result.language && {result.language}} -
-

{result.path}

-
- {!result.isDir && ( - {result.sizeText} - )} - -
+ entry={resultToEntry(result)} + onPrimaryClick={() => setMountEntry(result)} + onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }} + nameSlot={ + <> +
+ {result.name} + + {result.system && {result.system}} + {result.video && {result.video}} + {result.language && {result.language}} +
+
{result.path}
+ + } + /> ))}
@@ -507,6 +502,40 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver + {/* Actions dialog */} + !open && setActionEntry(null)}> + + + {actionEntry?.name} +

+ {actionEntry?.name} +

+ {actionEntry?.isDir ? 'Folder' : actionEntry?.sizeText} +
+
+ {!actionEntry?.isDir && ( + + )} + +
+
+
+ {/* Mount dialog — same pattern as MediaManager */} !open && setMountEntry(null)}> diff --git a/src/app/components/ui/marquee-text.tsx b/src/app/components/ui/marquee-text.tsx new file mode 100644 index 0000000..c339778 --- /dev/null +++ b/src/app/components/ui/marquee-text.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; + +let _seq = 0; + +/** + * Scrolls text left→right 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(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 ( + + + {children} + + + ); +}