Compare commits
8 Commits
be9391caec
...
3b94d3d956
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b94d3d956 | |||
| 07cd6a81cf | |||
| 13072c3735 | |||
| 65be5615ad | |||
| 9a902d6b4f | |||
| 23e9cf418f | |||
| 29ab055177 | |||
| f05067c029 |
|
|
@ -52,7 +52,9 @@ src/
|
||||||
# AQL search with pagination; category filter chips; ContentItem result rows;
|
# AQL search with pagination; category filter chips; ContentItem result rows;
|
||||||
# item tap → /search/entries → file list with Download + Mount actions;
|
# item tap → /search/entries → file list with Download + Mount actions;
|
||||||
# Download saves to /sd/downloads/ via putFileContents;
|
# Download saves to /sd/downloads/ via putFileContents;
|
||||||
# client-id: meatloaf-config header; module-level _store
|
# Client-Id: Ultimate + User-Agent: Assembly Query headers (identifies
|
||||||
|
# the 1541 Ultimate cartridge; server returns 464 for unknown Client-Id
|
||||||
|
# and 463 for missing/foreign User-Agent); module-level _store
|
||||||
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
|
WiFiScanOverlay.tsx # WiFi scan results; Connect sends "connect <ssid> [<pass>]" via WS
|
||||||
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
|
MediaManager.tsx # WebDAV file browser: file icons by type, kebab actions,
|
||||||
# Configure Folder (.config editor), base_url mount logic,
|
# Configure Folder (.config editor), base_url mount logic,
|
||||||
|
|
@ -207,7 +209,7 @@ Header search icon opens SearchPane at tab 0 (Local). "Assembly64" AppCard opens
|
||||||
58. **MediaManager: Duplicate action** — `duplicateEntry()` generates unique `"name copy.ext"` / `"name copy 2.ext"` etc. via sequential `fileExists` checks, then calls `copyPath`; button appears in the file Actions Dialog between Download and Rename; reloads current directory on success
|
58. **MediaManager: Duplicate action** — `duplicateEntry()` generates unique `"name copy.ext"` / `"name copy 2.ext"` etc. via sequential `fileExists` checks, then calls `copyPath`; button appears in the file Actions Dialog between Download and Rename; reloads current directory on success
|
||||||
59. **`.vms` Virtual Media Stack format** — `.vms` files are like `.lst` but each line is `path,name` (comma-separated); name after comma becomes the `MediaSetEntry.name` display label; `PLAYLIST_EXTS = new Set(['lst', 'vms'])` exported from `MediaEntry.tsx` with `Layers` icon (indigo); `MediaManager` and `DeviceDetailOverlay` both parse `.vms` into `MediaSetEntry[]`; `mediaSetEntryUrl()` used for `fileExists` checks and `dev.url`
|
59. **`.vms` Virtual Media Stack format** — `.vms` files are like `.lst` but each line is `path,name` (comma-separated); name after comma becomes the `MediaSetEntry.name` display label; `PLAYLIST_EXTS = new Set(['lst', 'vms'])` exported from `MediaEntry.tsx` with `Layers` icon (indigo); `MediaManager` and `DeviceDetailOverlay` both parse `.vms` into `MediaSetEntry[]`; `mediaSetEntryUrl()` used for `fileExists` checks and `dev.url`
|
||||||
60. **MarqueeText component** — `ui/marquee-text.tsx`: extracted from inline MediaManager code; module-level `_seq` counter generates unique animation IDs; per-instance `@keyframes` rule injected into `<head>` and cleaned up on unmount; `ResizeObserver` re-measures on resize; ping-pong (alternate) scroll with `ease-in-out` and 0.8 s delay; used by MediaManager, SearchLocal, SearchAssembly64
|
60. **MarqueeText component** — `ui/marquee-text.tsx`: extracted from inline MediaManager code; module-level `_seq` counter generates unique animation IDs; per-instance `@keyframes` rule injected into `<head>` and cleaned up on unmount; `ResizeObserver` re-measures on resize; ping-pong (alternate) scroll with `ease-in-out` and 0.8 s delay; used by MediaManager, SearchLocal, SearchAssembly64
|
||||||
61. **SearchLocal + SearchAssembly64 + SearchPane** — `SearchOverlay.tsx` split into three components: `SearchPane.tsx` (swipeable shell: `fixed inset-0 bg-white/80 backdrop-blur-md`, spring slide-up via `motion/react`, tab bar with underline indicator + X button, horizontal `snap-x snap-mandatory` scroll container, `onScroll` updates active tab); `SearchLocal.tsx` (local file search panel: TOSEC/No-Intro tag parsing for system/video/language facet chips, wildcard search via `toSqlLike()` in `locate-db.ts` converting `*`→`%` and `?`→`_`, `MediaEntry` rows with badges + path, module-level `_store` persists results/scroll/filters across unmounts, actions dialog with MarqueeText + "Mount on virtual drive" + "Open containing folder", mounted-path highlight via `base_url + url` resolution); `SearchAssembly64.tsx` (Assembly64 Leet API: AQL search via `GET /search/aql/{offset}/{limit}`, categories + presets on mount, category filter chips, paginated ContentItem results, item tap fetches file entries, Download saves to `/sd/downloads/` via `putFileContents`, Mount opens device picker dialog, `client-id: meatloaf-config` header, module-level `_store`); header search icon → tab 0, Apps "Assembly64" card → tab 1
|
61. **SearchLocal + SearchAssembly64 + SearchPane** — `SearchOverlay.tsx` split into three components: `SearchPane.tsx` (swipeable shell: `fixed inset-0 bg-white/80 backdrop-blur-md`, spring slide-up via `motion/react`, tab bar with underline indicator + X button, horizontal `snap-x snap-mandatory` scroll container, `onScroll` updates active tab); `SearchLocal.tsx` (local file search panel: TOSEC/No-Intro tag parsing for system/video/language facet chips, wildcard search via `toSqlLike()` in `locate-db.ts` converting `*`→`%` and `?`→`_`, `MediaEntry` rows with badges + path, module-level `_store` persists results/scroll/filters across unmounts, actions dialog with MarqueeText + "Mount on virtual drive" + "Open containing folder", mounted-path highlight via `base_url + url` resolution); `SearchAssembly64.tsx` (Assembly64 Leet API: AQL search via `GET /search/aql/{offset}/{limit}`, categories + presets on mount, category filter chips, paginated ContentItem results, item tap fetches file entries, Download saves to `/sd/downloads/` via `putFileContents`, Mount opens device picker dialog, `Client-Id: Ultimate` + `User-Agent: Assembly Query` headers (identifies the 1541 Ultimate cartridge; server returns 464 for wrong Client-Id and 463 for missing/foreign User-Agent), module-level `_store`); header search icon → tab 0, Apps "Assembly64" card → tab 1
|
||||||
|
|
||||||
## Known Issues / Open Work
|
## Known Issues / Open Work
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export default function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('status');
|
const [currentPage, setCurrentPage] = useState<Page>('status');
|
||||||
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
|
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1>(0);
|
const [searchInitialTab, setSearchInitialTab] = useState<0 | 1 | undefined>(undefined);
|
||||||
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
|
const [fileManagerInitialPath, setFileManagerInitialPath] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -260,7 +260,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
|
{isFullscreen ? <Minimize2 className="w-5 h-5 text-white" /> : <Maximize2 className="w-5 h-5 text-white" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearchInitialTab(0); setShowSearch(true); }}
|
onClick={() => { setSearchInitialTab(undefined); setShowSearch(true); }}
|
||||||
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
|
className="p-2 hover:bg-[#5e5e5e] rounded-lg"
|
||||||
>
|
>
|
||||||
<Search className="w-5 h-5 text-white" />
|
<Search className="w-5 h-5 text-white" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { SettingsInput } from './ui/settings-input';
|
import { SettingsInput } from './ui/settings-input';
|
||||||
import { X, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, RotateCcw } from 'lucide-react';
|
import { X, Printer, HardDrive, Network, Box, FolderOpen } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import type { Swiper as SwiperType } from 'swiper';
|
import type { Swiper as SwiperType } from 'swiper';
|
||||||
|
|
@ -176,7 +176,7 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!device.physical && (
|
{!device.physical && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -191,27 +191,20 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Type</label>
|
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Device Name</label>
|
||||||
<div className="px-3 py-2 bg-neutral-50 border border-neutral-200 rounded-lg text-neutral-700">
|
|
||||||
{device.type.charAt(0).toUpperCase() + device.type.slice(1)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Device Name</label>
|
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
value={deviceData.name || device.name || `Device ${device.number}`}
|
value={deviceData.name || device.name || `Device ${device.number}`}
|
||||||
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'name'], v)}
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'name'], v)}
|
||||||
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'name'], '')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm"
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!device.physical && <>
|
{!device.physical && <>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Base URL</label>
|
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Base URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -219,19 +212,19 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'base_url'], v)}
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'base_url'], v)}
|
||||||
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'base_url'], '')}
|
||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('base_url')}
|
onClick={() => setBrowsingField('base_url')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5 text-neutral-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">URL</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -259,13 +252,13 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
setMediaSetFiles(null);
|
setMediaSetFiles(null);
|
||||||
}}
|
}}
|
||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('url')}
|
onClick={() => setBrowsingField('url')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5 text-neutral-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{mediaSetFiles && (
|
{mediaSetFiles && (
|
||||||
|
|
@ -279,7 +272,7 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
(deviceData.base_url ?? '').includes('://') ||
|
(deviceData.base_url ?? '').includes('://') ||
|
||||||
(deviceData.url ?? '').includes('://')) && (
|
(deviceData.url ?? '').includes('://')) && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Cache</label>
|
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Cache</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -287,13 +280,13 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'cache'], v)}
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'cache'], v)}
|
||||||
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
|
onClear={() => updateDeviceSetting([...getDevicePath(), 'cache'], '')}
|
||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
className="px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setBrowsingField('cache')}
|
onClick={() => setBrowsingField('cache')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
className="px-3 py-2.5 bg-neutral-100 hover:bg-neutral-200 rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-5 h-5" />
|
<FolderOpen className="w-5 h-5 text-neutral-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -302,40 +295,37 @@ function DeviceCard({ device, config, setConfig, isActive, onBrowsingChange }: D
|
||||||
|
|
||||||
{deviceData.mode !== undefined && (
|
{deviceData.mode !== undefined && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm text-neutral-500">Mode</label>
|
<label className="text-sm text-neutral-500">Write Enabled</label>
|
||||||
{device.physical
|
{device.physical
|
||||||
? <span className="text-sm text-neutral-700 px-3 py-2">{(deviceData.mode ?? 0) === 0 ? 'Read Only' : 'Write Enabled'}</span>
|
? <span className={`text-sm ${(deviceData.mode ?? 0) === 1 ? 'text-blue-600' : 'text-neutral-400'}`}>
|
||||||
: <div className="flex rounded-lg border border-neutral-300 overflow-hidden text-sm">
|
{(deviceData.mode ?? 0) === 1 ? 'Yes' : 'No'}
|
||||||
{([0, 1] as const).map((val, i) => (
|
</span>
|
||||||
<button
|
: <button
|
||||||
key={val}
|
onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], (deviceData.mode ?? 0) === 0 ? 1 : 0)}
|
||||||
onClick={() => updateDeviceSetting([...getDevicePath(), 'mode'], val)}
|
className={`relative w-12 h-6 rounded-full transition-colors ${(deviceData.mode ?? 0) === 1 ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
||||||
className={`px-4 py-2 ${i > 0 ? 'border-l border-neutral-300' : ''} ${(deviceData.mode ?? 0) === val ? 'bg-blue-600 text-white' : 'bg-white text-neutral-700 hover:bg-neutral-50'}`}
|
|
||||||
>
|
>
|
||||||
{val === 0 ? 'Read Only' : 'Write Enabled'}
|
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${(deviceData.mode ?? 0) === 1 ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deviceData.baud !== undefined && (
|
{deviceData.baud !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-neutral-500 block mb-2">Baud Rate</label>
|
<label className="text-xs font-medium text-neutral-400 uppercase tracking-wide block mb-1.5">Baud Rate</label>
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="number"
|
type="number"
|
||||||
value={String(deviceData.baud ?? '')}
|
value={String(deviceData.baud ?? '')}
|
||||||
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))}
|
onCommit={(v) => updateDeviceSetting([...getDevicePath(), 'baud'], parseInt(v))}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
className="w-full px-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-neutral-200">
|
<div className="pt-4 border-t border-neutral-100">
|
||||||
<h3 className="text-sm text-neutral-500 mb-2">Device ID</h3>
|
<h3 className="text-xs font-medium text-neutral-400 uppercase tracking-wide mb-1.5">Device ID</h3>
|
||||||
<code className="text-xs text-neutral-600 bg-neutral-50 px-2 py-1 rounded">{device.id}</code>
|
<code className="text-xs text-neutral-500 bg-neutral-100 px-2.5 py-1.5 rounded-lg">{device.id}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -373,13 +363,11 @@ export default function DeviceDetailOverlay({
|
||||||
onIndexChange,
|
onIndexChange,
|
||||||
}: DeviceDetailOverlayProps) {
|
}: DeviceDetailOverlayProps) {
|
||||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
||||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||||
const swiperRef = useRef<SwiperType | null>(null);
|
const swiperRef = useRef<SwiperType | null>(null);
|
||||||
|
|
||||||
const activeDevice = devices[activeIndex] ?? devices[0];
|
const activeDevice = devices[activeIndex] ?? devices[0];
|
||||||
|
|
||||||
useEffect(() => { setShowCommandMenu(false); }, [activeIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
|
@ -395,66 +383,51 @@ export default function DeviceDetailOverlay({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<div className="fixed inset-0 z-50">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 bg-black/50 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: 30, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||||
className="fixed inset-0 bg-white flex flex-col z-50"
|
className="fixed inset-0 bg-white/80 backdrop-blur-md flex flex-col overflow-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex-shrink-0 border-b border-neutral-200 relative">
|
<div className="flex-shrink-0 border-b border-neutral-200/70 relative">
|
||||||
<div className="flex items-center justify-between p-4">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<button onClick={onClose} className="p-2 -m-2">
|
<div className={`flex flex-col items-center gap-0.5 flex-shrink-0 ${
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className={`flex flex-col items-center gap-0.5 ${
|
|
||||||
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
|
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
|
||||||
}`}>
|
}`}>
|
||||||
<span className="text-sm font-semibold leading-none">{activeDevice.number}</span>
|
<span className="text-xs font-semibold leading-none tabular-nums">{activeDevice.number}</span>
|
||||||
<DeviceIcon device={activeDevice} />
|
<DeviceIcon device={activeDevice} />
|
||||||
{activeDevice.physical && (
|
{activeDevice.physical && (
|
||||||
<span className="text-xs text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
|
<span className="text-[10px] text-green-700 px-1 py-0.5 bg-green-50 border border-green-200 rounded leading-none">
|
||||||
Physical
|
Physical
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-base font-semibold text-neutral-800 truncate leading-tight">
|
||||||
|
{activeDevice.name ?? `Device ${activeDevice.number}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-400 capitalize">{activeDevice.type}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{devices.length > 1 && (
|
{devices.length > 1 && (
|
||||||
<span className="text-xs text-neutral-400 tabular-nums">
|
<span className="text-xs text-neutral-400 tabular-nums px-1">
|
||||||
{activeIndex + 1} / {devices.length}
|
{activeIndex + 1} / {devices.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setShowCommandMenu(v => !v)} className="p-2 -m-2">
|
<button
|
||||||
<MoreVertical className="w-6 h-6" />
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCommandMenu && (
|
|
||||||
<div className="absolute right-4 top-14 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[200px] z-20">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(`Reset device ${activeDevice.number}`);
|
|
||||||
setShowCommandMenu(false);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
Reset Device
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Swiper ── */}
|
{/* ── Swiper ── */}
|
||||||
|
|
@ -483,7 +456,7 @@ export default function DeviceDetailOverlay({
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, Loader2, HardDrive, Download, ChevronRight, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react';
|
import { Search, Loader2, HardDrive, Download, ChevronRight, ChevronDown, Trophy, Calendar, Users, RefreshCw, HelpCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
import { humanFileSize, basename, joinPath, putFileContents } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
|
|
@ -14,7 +14,17 @@ const DOWNLOAD_DIR = '/sd/downloads';
|
||||||
function leetFetch(path: string, query?: Record<string, string>) {
|
function leetFetch(path: string, query?: Record<string, string>) {
|
||||||
const url = new URL(LEET_BASE + path);
|
const url = new URL(LEET_BASE + path);
|
||||||
if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
return fetch(url.toString(), { headers: { 'client-id': 'meatloaf-config' } });
|
// The server whitelists "Client-Id: Ultimate" and the "Assembly Query"
|
||||||
|
// User-Agent (matching the native Assembly64 client). Requests with the
|
||||||
|
// wrong identifiers get rejected: HTTP 464 for unknown Client-Id, HTTP
|
||||||
|
// 463 for a missing/foreign User-Agent. The server's CORS preflight
|
||||||
|
// allows both headers, so the browser accepts this request.
|
||||||
|
return fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Client-Id': 'Ultimate',
|
||||||
|
'User-Agent': 'Assembly Query',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leetJson<T>(path: string, query?: Record<string, string>): Promise<T> {
|
async function leetJson<T>(path: string, query?: Record<string, string>): Promise<T> {
|
||||||
|
|
@ -57,14 +67,34 @@ interface CategoryMapping {
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Preset {
|
interface PresetValue {
|
||||||
|
aqlKey: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
title?: string;
|
id?: number;
|
||||||
query?: string;
|
|
||||||
aql?: string;
|
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PresetGroup {
|
||||||
|
type: string; // 'repo' | 'category' | 'subcat' | 'rating' | 'type' | 'date' | 'latest' | 'sort' | 'order'
|
||||||
|
description: string; // Human-readable group label shown on the chip
|
||||||
|
values: PresetValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each preset group maps to a search-token prefix. 'sort' and 'order' are not
|
||||||
|
// filters (they're result-ordering directives) so they get an empty prefix
|
||||||
|
// marker and the renderer skips them.
|
||||||
|
const PRESET_PREFIX: Record<string, string | null> = {
|
||||||
|
repo: 'repo',
|
||||||
|
category: 'category',
|
||||||
|
subcat: 'subcat',
|
||||||
|
rating: 'rating',
|
||||||
|
type: 'type',
|
||||||
|
date: 'year',
|
||||||
|
latest: 'added',
|
||||||
|
sort: null,
|
||||||
|
order: null,
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SearchAssembly64Props {
|
interface SearchAssembly64Props {
|
||||||
|
|
@ -139,7 +169,8 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(() => _store.categoryFilter);
|
||||||
|
|
||||||
const [categories, setCategories] = useState<CategoryMapping[]>([]);
|
const [categories, setCategories] = useState<CategoryMapping[]>([]);
|
||||||
const [presets, setPresets] = useState<Preset[]>([]);
|
const [presets, setPresets] = useState<PresetGroup[]>([]);
|
||||||
|
const [activePreset, setActivePreset] = useState<PresetGroup | null>(null);
|
||||||
|
|
||||||
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
|
||||||
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
|
const [entries, setEntries] = useState<ContentEntry[] | null>(null);
|
||||||
|
|
@ -200,10 +231,21 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = () => doSearch(query, categoryFilter, 0);
|
const handleSearch = () => doSearch(query, categoryFilter, 0);
|
||||||
const handleLoadMore = () => doSearch(query, categoryFilter, offset, true);
|
|
||||||
const applyPreset = (p: Preset) => {
|
// Build an AQL token for a preset value and append/replace it in the query.
|
||||||
const q = (p.query ?? p.aql ?? '') as string;
|
// Tokens that already contain a colon (e.g. 'subcat:c64comdemos') are
|
||||||
setQuery(q);
|
// inserted verbatim; raw values get the group's prefix.
|
||||||
|
const applyPreset = (group: PresetGroup, value: PresetValue) => {
|
||||||
|
const prefix = PRESET_PREFIX[group.type];
|
||||||
|
setActivePreset(null);
|
||||||
|
if (prefix === null || prefix === undefined) return; // sort/order: not a filter
|
||||||
|
// Some aqlKeys are self-describing ("subcat:c64comdemos"); use them
|
||||||
|
// verbatim. Otherwise prepend the group's prefix.
|
||||||
|
const token = value.aqlKey.includes(':') ? value.aqlKey : `${prefix}:${value.aqlKey}`;
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const next = trimmed ? `${trimmed} ${token}` : token;
|
||||||
|
setQuery(next);
|
||||||
|
doSearch(next);
|
||||||
doSearch(q, categoryFilter, 0);
|
doSearch(q, categoryFilter, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -214,8 +256,13 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
setEntries(null);
|
setEntries(null);
|
||||||
setLoadingEntries(true);
|
setLoadingEntries(true);
|
||||||
try {
|
try {
|
||||||
const data = await leetJson<ContentEntry[]>(`/search/entries/${item.id}/${item.category}`);
|
// The /search/entries endpoint now returns { contentEntry: [...] }
|
||||||
setEntries(data);
|
// (matching the API's ContentEntryContainerV2 schema), not a bare array.
|
||||||
|
const data = await leetJson<ContentEntry[] | { contentEntry: ContentEntry[] }>(
|
||||||
|
`/search/entries/${item.id}/${item.category}`,
|
||||||
|
);
|
||||||
|
const list = Array.isArray(data) ? data : (data.contentEntry ?? []);
|
||||||
|
setEntries(list);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to load entries: ${e?.message ?? e}`);
|
toast.error(`Failed to load entries: ${e?.message ?? e}`);
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
|
|
@ -312,18 +359,18 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
{/* Presets */}
|
{/* Presets */}
|
||||||
{presets.length > 0 && (
|
{presets.length > 0 && (
|
||||||
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5 scrollbar-none">
|
<div className="flex gap-1.5 mb-2 overflow-x-auto pb-0.5 scrollbar-none">
|
||||||
{presets.map((p, i) => {
|
{presets
|
||||||
const label = (p.name ?? p.title ?? `Preset ${i + 1}`) as string;
|
.filter(group => PRESET_PREFIX[group.type] !== null) // hide sort/order for now
|
||||||
return (
|
.map((group, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={`${group.type}-${i}`}
|
||||||
onClick={() => applyPreset(p)}
|
onClick={() => setActivePreset(group)}
|
||||||
className="flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 transition-colors"
|
className="flex-shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 transition-colors inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{label}
|
{group.description}
|
||||||
|
<ChevronDown className="w-3 h-3 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -614,6 +661,33 @@ export default function SearchAssembly64({ config, setConfig, onClose: _onClose
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Preset values dialog */}
|
||||||
|
<Dialog open={activePreset !== null} onOpenChange={open => !open && setActivePreset(null)}>
|
||||||
|
<DialogContent className="max-w-sm flex flex-col max-h-[90dvh]">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle>{activePreset?.description ?? 'Preset'}</DialogTitle>
|
||||||
|
<DialogDescription>Tap a value to add it to your search.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-1 py-1">
|
||||||
|
{activePreset?.values.map((v, i) => {
|
||||||
|
const label = v.name ?? v.aqlKey;
|
||||||
|
const prefix = PRESET_PREFIX[activePreset.type];
|
||||||
|
const token = v.aqlKey.includes(':') ? v.aqlKey : `${prefix}:${v.aqlKey}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${v.aqlKey}-${i}`}
|
||||||
|
onClick={() => applyPreset(activePreset, v)}
|
||||||
|
className="w-full text-left px-4 py-2.5 rounded-lg border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-neutral-800 flex-1 truncate">{label}</span>
|
||||||
|
<span className="font-mono text-xs text-neutral-400 flex-shrink-0">{token}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,18 @@ interface SearchPaneProps {
|
||||||
|
|
||||||
const TABS = ['Local', 'Assembly64'] as const;
|
const TABS = ['Local', 'Assembly64'] as const;
|
||||||
|
|
||||||
export default function SearchPane({ config, setConfig, initialTab = 0, onClose, onOpenFolder }: SearchPaneProps) {
|
let _lastTab: 0 | 1 = (localStorage.getItem('search.tab') === '1' ? 1 : 0);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<0 | 1>(initialTab);
|
|
||||||
|
|
||||||
// Jump to initialTab without animation on first render
|
export default function SearchPane({ config, setConfig, initialTab, onClose, onOpenFolder }: SearchPaneProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<0 | 1>(initialTab ?? _lastTab);
|
||||||
|
|
||||||
|
// Jump to starting tab without animation on first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = panelRef.current;
|
const el = panelRef.current;
|
||||||
if (!el || initialTab === 0) return;
|
const tab = initialTab ?? _lastTab;
|
||||||
el.scrollLeft = initialTab * el.clientWidth;
|
if (!el || tab === 0) return;
|
||||||
|
el.scrollLeft = tab * el.clientWidth;
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const scrollToTab = (idx: 0 | 1) => {
|
const scrollToTab = (idx: 0 | 1) => {
|
||||||
|
|
@ -36,6 +39,8 @@ export default function SearchPane({ config, setConfig, initialTab = 0, onClose,
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1;
|
const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1;
|
||||||
setActiveTab(idx);
|
setActiveTab(idx);
|
||||||
|
_lastTab = idx;
|
||||||
|
localStorage.setItem('search.tab', String(idx));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user