feat(SearchAssembly64, SearchCSDbNG): improve null handling and enhance loading states
This commit is contained in:
parent
e420a47d03
commit
f280ad2ee9
|
|
@ -267,7 +267,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
const needle = filterText.trim().toLowerCase();
|
const needle = filterText.trim().toLowerCase();
|
||||||
let list = needle
|
let list = needle
|
||||||
? results.filter(r =>
|
? results.filter(r =>
|
||||||
r.name.toLowerCase().includes(needle) ||
|
(r.name ?? '').toLowerCase().includes(needle) ||
|
||||||
(r.group?.toLowerCase().includes(needle)) ||
|
(r.group?.toLowerCase().includes(needle)) ||
|
||||||
(r.handle?.toLowerCase().includes(needle))
|
(r.handle?.toLowerCase().includes(needle))
|
||||||
)
|
)
|
||||||
|
|
@ -276,7 +276,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
let cmp: number;
|
let cmp: number;
|
||||||
if (sortField === 'year') cmp = (a.year ?? 0) - (b.year ?? 0);
|
if (sortField === 'year') cmp = (a.year ?? 0) - (b.year ?? 0);
|
||||||
else if (sortField === 'rating') cmp = (a.siteRating ?? 0) - (b.siteRating ?? 0);
|
else if (sortField === 'rating') cmp = (a.siteRating ?? 0) - (b.siteRating ?? 0);
|
||||||
else cmp = a.name.localeCompare(b.name);
|
else cmp = (a.name ?? '').localeCompare(b.name ?? '');
|
||||||
return sortDir === 'asc' ? cmp : -cmp;
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
return list;
|
return list;
|
||||||
|
|
@ -287,6 +287,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
// ── Item entries ─────────────────────────────────────────────────────────────
|
// ── Item entries ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const openItem = async (item: ContentItem) => {
|
const openItem = async (item: ContentItem) => {
|
||||||
|
inputRef.current?.blur();
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
setEntries(null);
|
setEntries(null);
|
||||||
setLoadingEntries(true);
|
setLoadingEntries(true);
|
||||||
|
|
@ -515,11 +516,11 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
{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' : ''}{hasMore ? '+' : ''}
|
{visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}{hasMore ? '+' : ''}
|
||||||
</p>
|
</p>
|
||||||
{visibleResults.map(item => {
|
{visibleResults.map((item, idx) => {
|
||||||
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
|
const catLabel = categoryName[item.category] ?? `Cat ${item.category}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={`${item.id}-${idx}`}
|
||||||
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
|
className="flex items-stretch border-b border-neutral-100 border-l-2 border-l-transparent hover:bg-blue-50 hover:border-l-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -560,7 +561,7 @@ export default function SearchAssembly64({ config, setConfig, onClose }: SearchA
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActionItem(item)}
|
onClick={() => { inputRef.current?.blur(); setActionItem(item); }}
|
||||||
className="px-3 text-neutral-300 hover:text-neutral-600 flex-shrink-0 transition-colors"
|
className="px-3 text-neutral-300 hover:text-neutral-600 flex-shrink-0 transition-colors"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, Download, ChevronRight, X, Archive, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
import { Search, Loader2, HardDrive, ChevronRight, X, SlidersHorizontal, MoreVertical, FolderOpen } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { basename, joinPath, putFileContents } from '../webdav';
|
import { basename, joinPath, putFileContents } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MarqueeText } from './ui/marquee-text';
|
import { MarqueeText } from './ui/marquee-text';
|
||||||
|
import { EntryIcon } from './MediaEntry';
|
||||||
|
|
||||||
// ─── API ──────────────────────────────────────────────────────────────────────
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CSDB_API = 'https://api.idolpx.com/csdb';
|
const CSDB_API = 'https://api.idolpx.com/csdb';
|
||||||
const CSDB_HOST = 'https://csdb.dk';
|
const CSDB_DOWNLOAD = 'https://csdb.idolpx.com/data';
|
||||||
const DOWNLOAD_DIR = '/sd/downloads';
|
const DOWNLOAD_DIR = '/sd/downloads';
|
||||||
|
|
||||||
function resolveLink(link: string): string {
|
function resolveLink(link: string): string {
|
||||||
if (link.startsWith('http://') || link.startsWith('https://')) return link;
|
return CSDB_DOWNLOAD + link;
|
||||||
return CSDB_HOST + link;
|
}
|
||||||
|
|
||||||
|
function isRelativeLink(link: string): boolean {
|
||||||
|
return !link.startsWith('http://') && !link.startsWith('https://');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -89,7 +93,7 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
|
|
||||||
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
const [actionRow, setActionRow] = useState<CsdbRow | null>(null);
|
||||||
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
const [mountEntry, setMountEntry] = useState<{ row: CsdbRow; link: CsdbDownloadLink } | null>(null);
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
const [isMounting, setIsMounting] = useState(false);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
|
|
@ -150,42 +154,37 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Download to SD ───────────────────────────────────────────────────────────
|
// ── Mount ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const downloadToSd = async (row: CsdbRow, link: CsdbDownloadLink) => {
|
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
const url = resolveLink(link.Link);
|
if (!mountEntry) return;
|
||||||
setDownloading(url);
|
const { row, link } = mountEntry;
|
||||||
|
setIsMounting(true);
|
||||||
try {
|
try {
|
||||||
|
const url = resolveLink(link.Link);
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.arrayBuffer();
|
const data = await res.arrayBuffer();
|
||||||
const fname = link.Filename || basename(link.Link) || row.name;
|
const fname = link.Filename || basename(link.Link) || row.name;
|
||||||
const dest = joinPath(DOWNLOAD_DIR, fname);
|
const dest = joinPath(DOWNLOAD_DIR, fname);
|
||||||
await putFileContents(dest, data);
|
await putFileContents(dest, data);
|
||||||
toast.success(`Saved to ${dest}`);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Download failed: ${e?.message ?? e}`);
|
|
||||||
} finally {
|
|
||||||
setDownloading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Mount ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
|
||||||
if (!mountEntry) return;
|
|
||||||
const { row, link } = mountEntry;
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.devices) newConfig.devices = {};
|
if (!newConfig.devices) newConfig.devices = {};
|
||||||
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
if (!newConfig.devices.iec) newConfig.devices.iec = {};
|
||||||
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType };
|
||||||
const dev = newConfig.devices.iec[key];
|
const dev = newConfig.devices.iec[key];
|
||||||
dev.url = resolveLink(link.Link);
|
dev.url = dest;
|
||||||
delete dev.media_set;
|
delete dev.media_set;
|
||||||
if (!dev.enabled) dev.enabled = 1;
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setMountEntry(null);
|
setMountEntry(null);
|
||||||
toast.success(`Mounted "${link.Filename || row.name}" on ${deviceType} #${key}`);
|
setSelectedRow(null);
|
||||||
|
toast.success(`Downloaded and mounted "${fname}" on device #${key}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Mount failed: ${e?.message ?? e}`);
|
||||||
|
} finally {
|
||||||
|
setIsMounting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mountedUrls = useMemo(() => {
|
const mountedUrls = useMemo(() => {
|
||||||
|
|
@ -451,44 +450,27 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loadingRelease && release?.DownloadLinks.length === 0 && (
|
{!loadingRelease && release?.DownloadLinks.filter(l => isRelativeLink(l.Link)).length === 0 && (
|
||||||
<p className="text-sm text-neutral-400 text-center py-8">No download links found</p>
|
<p className="text-sm text-neutral-400 text-center py-8">No download links found</p>
|
||||||
)}
|
)}
|
||||||
{!loadingRelease && release && release.DownloadLinks.length > 0 && (
|
{!loadingRelease && release && release.DownloadLinks.some(l => isRelativeLink(l.Link)) && (
|
||||||
<div className="flex flex-col gap-2 py-1">
|
<div className="flex flex-col gap-2 py-1">
|
||||||
{release.DownloadLinks.map((link, i) => {
|
{release.DownloadLinks.filter(l => isRelativeLink(l.Link)).map((link, i) => {
|
||||||
const url = resolveLink(link.Link);
|
|
||||||
const isMounted = mountedUrls.has(url);
|
|
||||||
const fname = link.Filename || basename(link.Link) || selectedRow!.name;
|
const fname = link.Filename || basename(link.Link) || selectedRow!.name;
|
||||||
|
const localPath = joinPath(DOWNLOAD_DIR, fname);
|
||||||
|
const isMounted = mountedUrls.has(localPath);
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={`px-4 py-3 rounded-lg border flex items-center gap-3 ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200'}`}
|
onClick={() => setMountEntry({ row: selectedRow!, link })}
|
||||||
|
className={`px-4 py-3 rounded-lg border flex items-center gap-3 text-left w-full transition-colors ${isMounted ? 'border-blue-300 bg-blue-50' : 'border-neutral-200 hover:bg-blue-50 hover:border-blue-300'}`}
|
||||||
>
|
>
|
||||||
|
<EntryIcon entry={{ name: fname, type: 'file', path: link.Link, size: 0, lastModified: new Date(), contentType: '' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
<div className="text-sm font-medium text-neutral-800 truncate">{fname}</div>
|
||||||
<div className="text-xs text-neutral-400">
|
|
||||||
{link.Downloads > 0 ? `${link.Downloads.toLocaleString()} downloads` : link.Status}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{isMounted && <span className="text-xs text-blue-600 flex-shrink-0">Mounted</span>}
|
||||||
<button
|
|
||||||
onClick={() => downloadToSd(selectedRow!, link)}
|
|
||||||
disabled={downloading === url}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40"
|
|
||||||
title="Download to /sd/downloads"
|
|
||||||
>
|
|
||||||
{downloading === url
|
|
||||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
: <Download className="w-4 h-4" />}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setMountEntry({ row: selectedRow!, link })}
|
|
||||||
className={`p-1.5 rounded-lg ${isMounted ? 'text-blue-600' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
|
||||||
title="Mount on virtual drive"
|
|
||||||
>
|
|
||||||
<HardDrive className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -507,7 +489,13 @@ export default function SearchCSDbNG({ config, setConfig, onClose }: SearchCSDbN
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
{(() => {
|
{isMounting && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
||||||
|
<Loader2 className="w-7 h-7 text-blue-500 animate-spin" />
|
||||||
|
<p className="text-sm text-neutral-500">Downloading…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isMounting && (() => {
|
||||||
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
const allDevices = Object.entries(config?.devices?.iec ?? {});
|
||||||
const drives = allDevices
|
const drives = allDevices
|
||||||
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
.filter(([, v]: [string, any]) => (v as any)?.type === 'drive')
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,12 @@ function DialogClose({
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
const DialogOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
|
@ -43,18 +43,17 @@ function DialogOverlay({
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function DialogContent({
|
const DialogContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
children,
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
...props
|
>(({ className, children, ...props }, ref) => (
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
|
@ -69,8 +68,8 @@ function DialogContent({
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
));
|
||||||
}
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user