feat(SearchOverlay): add onOpenFolder prop and integrate folder opening functionality
This commit is contained in:
parent
86de697569
commit
451c9f665b
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<TypeBadge type={result.type} isDir={result.isDir} />
|
||||||
<div className="flex-1 min-w-0">
|
{result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>}
|
||||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
{result.video && <span className="text-xs px-1.5 py-0.5 rounded bg-teal-100 text-teal-700 font-mono">{result.video}</span>}
|
||||||
<span className="text-sm font-medium text-neutral-800 truncate">{result.name}</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>}
|
||||||
<TypeBadge type={result.type} isDir={result.isDir} />
|
</div>
|
||||||
{result.system && <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-mono">{result.system}</span>}
|
<div className="text-xs text-neutral-400 truncate">{result.path}</div>
|
||||||
{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>}
|
}
|
||||||
</div>
|
/>
|
||||||
<p className="text-xs text-neutral-400 truncate mt-0.5">{result.path}</p>
|
|
||||||
</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]">
|
||||||
|
|
|
||||||
66
src/app/components/ui/marquee-text.tsx
Normal file
66
src/app/components/ui/marquee-text.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user