diff --git a/src/app/App.tsx b/src/app/App.tsx index eda1d23..91d69a0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,7 @@ import GeneralPage from './components/GeneralPage'; import NetworkPage from './components/NetworkPage'; import IECPage from './components/IECPage'; import ToolsPage from './components/ToolsPage'; -import SearchOverlay from './components/SearchOverlay'; +import SearchPane from './components/SearchPane'; import RealityOverrideAdminPage from './components/RealityOverrideAdminPage'; import MediaManager from './components/MediaManager'; import ProfilePage from './components/ProfilePage'; @@ -54,7 +54,8 @@ type AppId = export default function App() { const [currentPage, setCurrentPage] = useState('status'); 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 [devicesOpenId, setDevicesOpenId] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const [fileManagerInitialPath, setFileManagerInitialPath] = useState(undefined); @@ -117,6 +118,7 @@ export default function App() {

Management

} label="Media Manager" onClick={() => { setFileManagerInitialPath(undefined); setCurrentPage('file-manager'); }} /> + } label="Assembly64" onClick={() => { setSearchInitialTab(1); setShowSearch(true); }} /> } label="Print Manager" onClick={() => setCurrentPage('print-manager')} /> } label="Serial Console" onClick={() => setCurrentPage('serial-console')} /> } label="Short Codes" onClick={() => setCurrentPage('serial-console')} /> @@ -258,7 +260,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) { {isFullscreen ? : }
diff --git a/src/app/components/SearchAssembly64.tsx b/src/app/components/SearchAssembly64.tsx new file mode 100644 index 0000000..0de5ebe --- /dev/null +++ b/src/app/components/SearchAssembly64.tsx @@ -0,0 +1,555 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Search, Loader2, HardDrive, Download, ChevronRight, Trophy, Calendar, Users, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import { humanFileSize, basename, joinPath, putFileContents } from '../webdav'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; +import { MarqueeText } from './ui/marquee-text'; + +// ─── API ────────────────────────────────────────────────────────────────────── + +const LEET_BASE = 'https://hackerswithstyle.se/leet'; +const PAGE_SIZE = 50; +const DOWNLOAD_DIR = '/sd/downloads'; + +function leetFetch(path: string, query?: Record) { + const url = new URL(LEET_BASE + path); + if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); + return fetch(url.toString(), { headers: { 'client-id': 'meatloaf-config' } }); +} + +async function leetJson(path: string, query?: Record): Promise { + const res = await leetFetch(path, query); + const data = await res.json(); + if (!res.ok || (data as any).errorCode) throw new Error(`Leet API error ${(data as any).errorCode ?? res.status}`); + return data as T; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ContentItem { + id: string; + name: string; + category: number; + group?: string; + handle?: string; + year?: number; + country?: string; + event?: string; + rating?: number; + siteRating?: number; + place?: number; + compo?: number; + updated?: string; + released?: string; +} + +interface ContentEntry { + id: number; + path: string; + size: number; + date: number; +} + +interface CategoryMapping { + id: number; + name?: string; + title?: string; + [k: string]: unknown; +} + +interface Preset { + name?: string; + title?: string; + query?: string; + aql?: string; + [k: string]: unknown; +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface SearchAssembly64Props { + config: any; + setConfig: (c: any) => void; + onClose: () => void; +} + +// ─── Module-level persistence ───────────────────────────────────────────────── + +const _store = { + query: '', + results: [] as ContentItem[], + offset: 0, + hasMore: false, + hasSearched: false, + categoryFilter: null as number | null, + scrollTop: 0, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function entryFilename(e: ContentEntry): string { + return basename(e.path) || e.path; +} + +function downloadUrl(item: ContentItem, entry: ContentEntry): string { + return `${LEET_BASE}/search/bin/${item.id}/${item.category}/${entry.id}`; +} + +function RatingStars({ value, max = 10 }: { value?: number; max?: number }) { + if (!value) return null; + const pct = Math.round((value / max) * 5); + return ( + + {'★'.repeat(pct)}{'☆'.repeat(5 - pct)} + + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function SearchAssembly64({ config, setConfig, onClose: _onClose }: SearchAssembly64Props) { + const scrollRef = useRef(null); + + const [query, setQuery] = useState(() => _store.query); + const [results, setResults] = useState(() => _store.results); + const [offset, setOffset] = useState(() => _store.offset); + const [hasMore, setHasMore] = useState(() => _store.hasMore); + const [hasSearched, setHasSearched] = useState(() => _store.hasSearched); + const [isSearching, setIsSearching] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [searchError, setSearchError] = useState(null); + const [categoryFilter, setCategoryFilter] = useState(() => _store.categoryFilter); + + const [categories, setCategories] = useState([]); + const [presets, setPresets] = useState([]); + + const [selectedItem, setSelectedItem] = useState(null); + const [entries, setEntries] = useState(null); + const [loadingEntries, setLoadingEntries] = useState(false); + + const [mountEntry, setMountEntry] = useState<{ item: ContentItem; entry: ContentEntry } | null>(null); + const [downloading, setDownloading] = useState(null); + + useEffect(() => { + _store.query = query; + _store.results = results; + _store.offset = offset; + _store.hasMore = hasMore; + _store.hasSearched = hasSearched; + _store.categoryFilter = categoryFilter; + }, [query, results, offset, hasMore, hasSearched, categoryFilter]); + + useEffect(() => { + if (_store.scrollTop > 0 && scrollRef.current) + scrollRef.current.scrollTop = _store.scrollTop; + }, []); + + useEffect(() => { + leetJson('/search/categories').then(setCategories).catch(() => {}); + leetJson('/search/aql/presets').then(setPresets).catch(() => {}); + }, []); + + const categoryName = useMemo(() => { + const map: Record = {}; + for (const c of categories) map[c.id] = (c.name ?? c.title ?? String(c.id)) as string; + return map; + }, [categories]); + + // ── Search ────────────────────────────────────────────────────────────────── + + const doSearch = async (q: string, cat: number | null, fromOffset: number, append = false) => { + if (!append) setIsSearching(true); + else setIsLoadingMore(true); + setSearchError(null); + try { + let aql = q.trim(); + if (cat !== null) aql = aql ? `${aql} category:${cat}` : `category:${cat}`; + const data = await leetJson( + `/search/aql/${fromOffset}/${PAGE_SIZE}`, + aql ? { query: aql } : undefined, + ); + if (append) setResults(prev => [...prev, ...data]); + else setResults(data); + setOffset(fromOffset + data.length); + setHasMore(data.length === PAGE_SIZE); + setHasSearched(true); + } catch (e: any) { + setSearchError(e?.message ?? 'Search failed'); + } finally { + setIsSearching(false); + setIsLoadingMore(false); + } + }; + + const handleSearch = () => doSearch(query, categoryFilter, 0); + const handleLoadMore = () => doSearch(query, categoryFilter, offset, true); + const applyPreset = (p: Preset) => { + const q = (p.query ?? p.aql ?? '') as string; + setQuery(q); + doSearch(q, categoryFilter, 0); + }; + + // ── Item entries ───────────────────────────────────────────────────────────── + + const openItem = async (item: ContentItem) => { + setSelectedItem(item); + setEntries(null); + setLoadingEntries(true); + try { + const data = await leetJson(`/search/entries/${item.id}/${item.category}`); + setEntries(data); + } catch (e: any) { + toast.error(`Failed to load entries: ${e?.message ?? e}`); + setEntries([]); + } finally { + setLoadingEntries(false); + } + }; + + // ── Download to SD ─────────────────────────────────────────────────────────── + + const downloadToSd = async (item: ContentItem, entry: ContentEntry) => { + setDownloading(entry.id); + try { + const url = downloadUrl(item, entry); + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.arrayBuffer(); + const dest = joinPath(DOWNLOAD_DIR, entryFilename(entry)); + 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 { item, entry } = mountEntry; + const newConfig = JSON.parse(JSON.stringify(config)); + if (!newConfig.devices) newConfig.devices = {}; + if (!newConfig.devices.iec) newConfig.devices.iec = {}; + if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType }; + const dev = newConfig.devices.iec[key]; + dev.url = downloadUrl(item, entry); + delete dev.media_set; + if (!dev.enabled) dev.enabled = 1; + setConfig(newConfig); + setMountEntry(null); + toast.success(`Mounted "${entryFilename(entry)}" on ${deviceType} #${key}`); + }; + + const mountedUrls = useMemo(() => { + const s = new Set(); + for (const d of Object.values(config?.devices?.iec ?? {})) { + const dev = d as any; + if (dev?.url) s.add(dev.url); + } + return s; + }, [config]); + + // ── Render ──────────────────────────────────────────────────────────────────── + + return ( + <> +
+ {/* Header */} +
+ {/* Search input */} +
+
+ + setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !isSearching && handleSearch()} + placeholder="name:manic* group:ultimate year:1983…" + className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors" + disabled={isSearching} + /> +
+ +
+ + {/* Presets */} + {presets.length > 0 && ( +
+ {presets.map((p, i) => { + const label = (p.name ?? p.title ?? `Preset ${i + 1}`) as string; + return ( + + ); + })} +
+ )} + + {/* Category filter */} + {categories.length > 0 && ( +
+ + {categories.map(c => { + const name = (c.name ?? c.title ?? String(c.id)) as string; + return ( + + ); + })} +
+ )} +
+ + {/* Body */} +
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} + > + {isSearching && !hasSearched && ( +
+ +

Searching…

+
+ )} + + {searchError && ( +
+

{searchError}

+ +
+ )} + + {!searchError && hasSearched && ( + <> + {results.length > 0 ? ( + <> +

+ {isSearching && } + {results.length} result{results.length !== 1 ? 's' : ''}{hasMore ? '+' : ''} +

+ {results.map(item => { + const catLabel = categoryName[item.category] ?? `Cat ${item.category}`; + return ( + + ); + })} + + {hasMore && ( +
+ +
+ )} + + ) : ( +
+ +

No results

+
+ )} + + )} + + {!hasSearched && !isSearching && ( +
+ +

Search the Assembly64 database

+

+ Use AQL syntax: name:manic*,{' '} + group:triad,{' '} + year:1983 +

+
+ )} +
+
+ + {/* Item entries dialog */} + !open && setSelectedItem(null)}> + + + {selectedItem?.name} +

+ {selectedItem?.name} +

+ + {[selectedItem?.group, selectedItem?.year, selectedItem?.event].filter(Boolean).join(' · ')} + +
+ +
+ {loadingEntries && ( +
+ +
+ )} + {!loadingEntries && entries?.length === 0 && ( +

No files found

+ )} + {!loadingEntries && entries && entries.length > 0 && ( +
+ {entries.map(entry => { + const fname = entryFilename(entry); + const isMounted = mountedUrls.has(downloadUrl(selectedItem!, entry)); + return ( +
+
+
{fname}
+
{humanFileSize(entry.size)}
+
+ + +
+ ); + })} +
+ )} +
+
+
+ + {/* Mount device picker */} + !open && setMountEntry(null)}> + + + Mount on Virtual Drive + + {mountEntry ? entryFilename(mountEntry.entry) : ''} + + +
+ {(() => { + const allDevices = Object.entries(config?.devices?.iec ?? {}); + const drives = allDevices + .filter(([, v]: [string, any]) => (v as any)?.type === 'drive') + .map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); + const meatloafs = allDevices + .filter(([, v]: [string, any]) => (v as any)?.type === 'meatloaf') + .map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); + const devices = [...drives, ...meatloafs]; + if (!devices.length) + return

No drive devices found in config.

; + return ( +
+ {devices.map(dev => ( + + ))} +
+ ); + })()} +
+
+
+ + ); +} diff --git a/src/app/components/SearchOverlay.tsx b/src/app/components/SearchLocal.tsx similarity index 62% rename from src/app/components/SearchOverlay.tsx rename to src/app/components/SearchLocal.tsx index f0f540c..7e6dba4 100644 --- a/src/app/components/SearchOverlay.tsx +++ b/src/app/components/SearchLocal.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; -import { X, Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react'; -import { motion, AnimatePresence } from 'motion/react'; +import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react'; import { toast } from 'sonner'; import { humanFileSize, splitPath } from '../webdav'; import type { EntryInfo } from '../webdav'; @@ -18,7 +17,7 @@ import { type ScanPhase, } from '../locate-db'; -interface SearchOverlayProps { +interface SearchLocalProps { config: any; setConfig: (config: any) => void; onClose: () => void; @@ -41,7 +40,6 @@ const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib' const CART_EXTS = new Set(['crt','bin']); const PRG_EXTS = new Set(['prg','p00']); -// Common ISO 639-1 codes seen in TOSEC / No-Intro filenames const LANG_CODES = ['En','De','Fr','Es','It','Nl','Sv','Da','Fi','Pt','Pl','Ru','Ja','Ko','Zh','Cs','Hu','No']; const LANG_NAMES: Record = { En:'English', De:'German', Fr:'French', Es:'Spanish', It:'Italian', @@ -127,7 +125,6 @@ function scanPhaseLabel(phase: ScanPhase, value: number): string { type SortField = 'name' | 'size'; type SortDir = 'asc' | 'desc'; -// Persisted across overlay open/close cycles (survives unmount). const _store = { query: '', results: [] as SearchResult[], @@ -166,7 +163,7 @@ function FilterChips({ ); } -export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder }: SearchOverlayProps) { +export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) { const scrollRef = useRef(null); const [query, setQuery] = useState(() => _store.query); const [isSearching, setIsSearching] = useState(false); @@ -191,7 +188,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder if (isLocateDbLoaded()) setDbPhase('ready'); }, []); - // Persist search state so it survives overlay close/reopen. useEffect(() => { _store.query = query; _store.results = results; @@ -204,14 +200,12 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder _store.sortDir = sortDir; }, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]); - // Restore scroll position after results render. useEffect(() => { if (_store.scrollTop > 0 && scrollRef.current) { scrollRef.current.scrollTop = _store.scrollTop; } }, []); - // Build a set of fully-resolved paths currently mounted on any IEC device. const mountedPaths = useMemo(() => { const paths = new Set(); for (const d of Object.values(config?.devices?.iec ?? {})) { @@ -308,7 +302,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder const busy = isSearching || isScanning; - // Collect unique facet values from the full result set const facets = useMemo(() => { const systems = [...new Set(results.map(r => r.system).filter(Boolean) as string[])].sort(); const videos = [...new Set(results.map(r => r.video).filter(Boolean) as string[])].sort(); @@ -344,209 +337,184 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length; return ( - - - - {/* Header */} -
-
-

Search

-
- {dbPhase === 'ready' && !busy && ( - - )} - - {hasAnyFacets && ( - - )} - -
-
- - {/* Search input */} -
-
- - setQuery(e.target.value)} - onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()} - placeholder="Search… (* any chars, ? one char)" - className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors" - autoFocus - disabled={busy} - /> -
+ <> +
+ {/* Header */} +
+
+ {dbPhase === 'ready' && !busy && ( -
- - {/* Filter panel */} -
-
- - - -
- Sort - - -
-
-
- -
- - {/* Body */} -
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} - > - {/* Scanning progress */} - {isScanning && scanPhase && ( -
- -

{scanPhaseLabel(scanPhase, scanValue)}

-

Scanning /sd recursively…

-
)} - - {/* Spinner — only when no prior results to show */} - {isSearching && !hasSearched && ( -
- -

{loadingLabel}

-
- )} - - {/* Error */} - {!busy && searchError && ( -
-

{searchError}

-
- )} - - {/* Results */} - {!searchError && hasSearched && ( -
- {visibleResults.length > 0 ? ( - <> -

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

-
- {visibleResults.map((result) => ( - setMountEntry(result)} - onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }} - selected={mountedPaths.has(result.path)} - nameSlot={ - <> -
- {result.name} - - {result.system && {result.system}} - {result.video && {result.video}} - {result.language && {result.language}} -
-
{result.path}
- - } - /> - ))} -
- - ) : ( -
- -

- {results.length > 0 ? 'No results match the current filters' : `No results for "${query}"`} -

- {results.length > 0 && ( - - )} -
+ + {hasAnyFacets && ( +
- )} - - {/* Empty state */} - {!isSearching && !hasSearched && ( -
- -

Search your device

-

- {dbPhase === 'idle' - ? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.' - : 'Type a filename or path to search the index.'} -

-
+ )}
- - + + {/* Search input */} +
+
+ + setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()} + placeholder="Search… (* any chars, ? one char)" + className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors" + autoFocus + disabled={busy} + /> +
+ +
+ + {/* Filter panel */} +
+
+ + + +
+ Sort + + +
+
+
+
+ + {/* Body */} +
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} + > + {isScanning && scanPhase && ( +
+ +

{scanPhaseLabel(scanPhase, scanValue)}

+

Scanning /sd recursively…

+
+ )} + + {isSearching && !hasSearched && ( +
+ +

{loadingLabel}

+
+ )} + + {!busy && searchError && ( +
+

{searchError}

+
+ )} + + {!searchError && hasSearched && ( +
+ {visibleResults.length > 0 ? ( + <> +

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

+
+ {visibleResults.map((result) => ( + setMountEntry(result)} + onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }} + selected={mountedPaths.has(result.path)} + nameSlot={ + <> +
+ {result.name} + + {result.system && {result.system}} + {result.video && {result.video}} + {result.language && {result.language}} +
+
{result.path}
+ + } + /> + ))} +
+ + ) : ( +
+ +

+ {results.length > 0 ? 'No results match the current filters' : `No results for "${query}"`} +

+ {results.length > 0 && ( + + )} +
+ )} +
+ )} + + {!isSearching && !hasSearched && ( +
+ +

Search your device

+

+ {dbPhase === 'idle' + ? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.' + : 'Type a filename or path to search the index.'} +

+
+ )} +
+
{/* Actions dialog */} !open && setActionEntry(null)}> @@ -582,7 +550,7 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder - {/* Mount dialog — same pattern as MediaManager */} + {/* Mount dialog */} !open && setMountEntry(null)}> @@ -636,6 +604,6 @@ export default function SearchOverlay({ config, setConfig, onClose, onOpenFolder
-
+ ); } diff --git a/src/app/components/SearchPane.tsx b/src/app/components/SearchPane.tsx new file mode 100644 index 0000000..67e8438 --- /dev/null +++ b/src/app/components/SearchPane.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import { X } from 'lucide-react'; +import { motion } from 'motion/react'; +import SearchLocal from './SearchLocal'; +import SearchAssembly64 from './SearchAssembly64'; + +interface SearchPaneProps { + config: any; + setConfig: (c: any) => void; + initialTab?: 0 | 1; + onClose: () => void; + onOpenFolder: (path: string) => void; +} + +const TABS = ['Local', 'Assembly64'] as const; + +export default function SearchPane({ config, setConfig, initialTab = 0, onClose, onOpenFolder }: SearchPaneProps) { + const panelRef = useRef(null); + const [activeTab, setActiveTab] = useState<0 | 1>(initialTab); + + // Jump to initialTab without animation on first render + useEffect(() => { + const el = panelRef.current; + if (!el || initialTab === 0) return; + el.scrollLeft = initialTab * el.clientWidth; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const scrollToTab = (idx: 0 | 1) => { + const el = panelRef.current; + if (!el) return; + el.scrollTo({ left: idx * el.clientWidth, behavior: 'smooth' }); + }; + + const handleScroll = () => { + const el = panelRef.current; + if (!el) return; + const idx = Math.round(el.scrollLeft / el.clientWidth) as 0 | 1; + setActiveTab(idx); + }; + + return ( +
+ + {/* Tab bar */} +
+ {TABS.map((label, i) => ( + + ))} +
+ +
+ + {/* Swipeable panels */} +
+ {/* Local panel */} +
+ +
+ + {/* Assembly64 panel */} +
+ +
+
+ +
+ ); +}