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}
|
||||
setConfig={setConfig}
|
||||
onClose={() => setShowSearch(false)}
|
||||
onOpenFolder={(path) => {
|
||||
setFileManagerInitialPath(path);
|
||||
setFileManagerReturnPage('status');
|
||||
setShowSearch(false);
|
||||
setCurrentPage('file-manager');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
import { MarqueeText } from './ui/marquee-text';
|
||||
|
||||
// ─── ActionsModal ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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();
|
||||
|
|
@ -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<SearchResult[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
||||
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [dbBytes, setDbBytes] = useState<number | null>(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"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
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"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="fixed inset-0 bg-white/95 backdrop-blur-sm flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<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 */}
|
||||
{!searchError && hasSearched && (
|
||||
<div className="p-3">
|
||||
<div>
|
||||
{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" />}
|
||||
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
{visibleResults.map((result) => (
|
||||
<div
|
||||
<MediaEntry
|
||||
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"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-white border border-neutral-200 flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
{result.isDir
|
||||
? <Folder className="w-4 h-4 text-amber-500" />
|
||||
: <File className="w-4 h-4 text-blue-500" />
|
||||
}
|
||||
</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} />
|
||||
{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.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>
|
||||
entry={resultToEntry(result)}
|
||||
onPrimaryClick={() => setMountEntry(result)}
|
||||
onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }}
|
||||
nameSlot={
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-neutral-900 text-sm">{result.name}</span>
|
||||
<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.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>
|
||||
<div className="text-xs text-neutral-400 truncate">{result.path}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -507,6 +502,40 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
</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 */}
|
||||
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||
<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