feat(StatusPage): add print log functionality and enhance UI elements
This commit is contained in:
parent
7ca7d478cc
commit
f9103455ed
|
|
@ -19,6 +19,7 @@ import {
|
||||||
File,
|
File,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FileText,
|
FileText,
|
||||||
|
FileType,
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
|
@ -55,6 +56,7 @@ import {
|
||||||
deletePath,
|
deletePath,
|
||||||
fileExists,
|
fileExists,
|
||||||
getFileContents,
|
getFileContents,
|
||||||
|
getWebDAVBaseUrl,
|
||||||
humanFileSize,
|
humanFileSize,
|
||||||
joinPath,
|
joinPath,
|
||||||
listDirectory,
|
listDirectory,
|
||||||
|
|
@ -77,11 +79,12 @@ import {
|
||||||
|
|
||||||
type SortKey = 'name' | 'size' | 'date';
|
type SortKey = 'name' | 'size' | 'date';
|
||||||
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
||||||
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code';
|
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config' | 'code' | 'doc';
|
||||||
|
|
||||||
// ─── Extension sets ──────────────────────────────────────────────────────────
|
// ─── Extension sets ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'seq', 'log', 'csv', 'lst']);
|
||||||
|
const DOC_EXTS = new Set(['doc', 'docx', 'odt', 'rtf', 'pdf', 'pages', 'tex', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']);
|
||||||
const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
|
const CODE_EXTS = new Set(['asm', 'bas', 's', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'py', 'c', 'cpp', 'h', 'hpp', 'lua', 'sh', 'bash', 'php', 'rb', 'rs', 'go', 'java', 'cs', 'kt', 'sql', 'pl']);
|
||||||
const MD_EXTS = new Set(['md', 'markdown']);
|
const MD_EXTS = new Set(['md', 'markdown']);
|
||||||
const JSON_EXTS = new Set(['json', 'webmanifest']);
|
const JSON_EXTS = new Set(['json', 'webmanifest']);
|
||||||
|
|
@ -104,6 +107,7 @@ function defaultViewMode(entry: EntryInfo): ViewMode {
|
||||||
if (JSON_EXTS.has(ext)) return 'json';
|
if (JSON_EXTS.has(ext)) return 'json';
|
||||||
if (XML_EXTS.has(ext)) return 'xml';
|
if (XML_EXTS.has(ext)) return 'xml';
|
||||||
if (CODE_EXTS.has(ext)) return 'code';
|
if (CODE_EXTS.has(ext)) return 'code';
|
||||||
|
if (DOC_EXTS.has(ext)) return 'doc';
|
||||||
if (TEXT_EXTS.has(ext)) return 'text';
|
if (TEXT_EXTS.has(ext)) return 'text';
|
||||||
return 'hex';
|
return 'hex';
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +121,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
||||||
json: ['json', 'text', 'hex'],
|
json: ['json', 'text', 'hex'],
|
||||||
xml: ['xml', 'text', 'hex'],
|
xml: ['xml', 'text', 'hex'],
|
||||||
code: ['code', 'text', 'hex'],
|
code: ['code', 'text', 'hex'],
|
||||||
|
doc: ['doc'],
|
||||||
text: ['text', 'hex'],
|
text: ['text', 'hex'],
|
||||||
hex: ['hex', 'text'],
|
hex: ['hex', 'text'],
|
||||||
};
|
};
|
||||||
|
|
@ -124,7 +129,7 @@ function availableViewers(entry: EntryInfo): ViewMode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWER_LABEL: Record<ViewMode, string> = {
|
const VIEWER_LABEL: Record<ViewMode, string> = {
|
||||||
code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML', hex: 'Hex', image: 'Image', config: 'Config',
|
code: 'Code', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'HTML/XML', hex: 'Hex', image: 'Image', config: 'Config', doc: 'Document',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXT_TO_LANG: Record<string, string> = {
|
const EXT_TO_LANG: Record<string, string> = {
|
||||||
|
|
@ -276,6 +281,7 @@ function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
|
||||||
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
|
||||||
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
|
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
|
||||||
|
if (DOC_EXTS.has(ext)) return <FileType className="w-5 h-5 text-blue-400 flex-shrink-0" />;
|
||||||
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2 } from 'lucide-react';
|
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, FileText, Power, Computer, MoreVertical, Download, Trash2, Eye, File as FileIcon } from 'lucide-react';
|
||||||
|
import { listDirectory, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, type EntryInfo } from '../webdav';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useWs } from '../ws';
|
import { useWs } from '../ws';
|
||||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import MediaSet from './MediaSet';
|
import MediaSet from './MediaSet';
|
||||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
|
|
||||||
interface StatusPageProps {
|
interface StatusPageProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -80,12 +82,58 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null);
|
const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null);
|
||||||
const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done'
|
const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done'
|
||||||
|
|
||||||
|
// Print Log
|
||||||
|
const [printFiles, setPrintFiles] = useState<EntryInfo[]>([]);
|
||||||
|
const [printLoading, setPrintLoading] = useState(true);
|
||||||
|
const [printActionEntry, setPrintActionEntry] = useState<EntryInfo | null>(null);
|
||||||
|
|
||||||
|
const loadPrintFiles = () => {
|
||||||
|
setPrintLoading(true);
|
||||||
|
listDirectory('/sd/.print')
|
||||||
|
.then(entries => setPrintFiles(entries.filter(e => e.type === 'file')))
|
||||||
|
.catch(() => setPrintFiles([]))
|
||||||
|
.finally(() => setPrintLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadPrintFiles(); }, []);
|
||||||
|
|
||||||
|
const printFileUrl = (entry: EntryInfo) => getWebDAVBaseUrl() + entry.path;
|
||||||
|
|
||||||
|
const downloadPrintFile = async (entry: EntryInfo) => {
|
||||||
|
try {
|
||||||
|
const blob = await getFileContents(entry.path);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = entry.name; a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePrintFile = async (entry: EntryInfo) => {
|
||||||
|
if (!window.confirm(`Delete "${entry.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deletePath(entry.path);
|
||||||
|
toast.success(`Deleted ${entry.name}`);
|
||||||
|
loadPrintFiles();
|
||||||
|
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const printFileIcon = (entry: EntryInfo) => {
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (['txt', 'log', 'prn', 'lst'].includes(ext))
|
||||||
|
return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
|
return <FileIcon className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
|
||||||
{activeDevice && (
|
{activeDevice && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm text-neutral-500 pt-2">Active Device</h2>
|
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
Active Device
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="bg-white border border-neutral-200 rounded-lg p-4 relative"
|
className="bg-white border border-neutral-200 rounded-lg p-4 relative"
|
||||||
|
|
@ -238,6 +286,80 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
Print Log
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="bg-white border border-neutral-200 rounded-lg overflow-hidden">
|
||||||
|
{printLoading ? (
|
||||||
|
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : printFiles.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-neutral-400">No print files found in /sd/.print</div>
|
||||||
|
) : (
|
||||||
|
printFiles.map(entry => (
|
||||||
|
<div key={entry.path} className="px-4 py-3 flex items-center gap-3 border-b border-neutral-100 last:border-b-0 hover:bg-neutral-50">
|
||||||
|
<button
|
||||||
|
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
||||||
|
onClick={() => window.open(printFileUrl(entry), '_blank')}
|
||||||
|
>
|
||||||
|
{printFileIcon(entry)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-neutral-900 truncate text-sm">{entry.name}</div>
|
||||||
|
<div className="text-xs text-neutral-400 truncate">
|
||||||
|
{humanFileSize(entry.size)}
|
||||||
|
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setPrintActionEntry(entry); }}
|
||||||
|
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
|
||||||
|
title="Actions"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Print Log action modal */}
|
||||||
|
<Dialog open={printActionEntry !== null} onOpenChange={open => !open && setPrintActionEntry(null)}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="truncate">{printActionEntry?.name}</DialogTitle>
|
||||||
|
<DialogDescription>{humanFileSize(printActionEntry?.size ?? 0)}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{printActionEntry && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setPrintActionEntry(null); window.open(printFileUrl(printActionEntry), '_blank'); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-blue-600" /> <span>Open / View</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setPrintActionEntry(null); void downloadPrintFile(printActionEntry); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> <span>Download</span>
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-neutral-100" />
|
||||||
|
<button
|
||||||
|
onClick={() => { setPrintActionEntry(null); void deletePrintFile(printActionEntry); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2">
|
<h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2">
|
||||||
<Activity className="w-4 h-4" />
|
<Activity className="w-4 h-4" />
|
||||||
Activity Log
|
Activity Log
|
||||||
|
|
@ -272,7 +394,10 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-sm text-neutral-500">System Status</h2>
|
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
|
||||||
|
<Computer className="w-4 h-4" />
|
||||||
|
System Status
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="bg-white border border-neutral-200 rounded-lg p-4">
|
<div className="bg-white border border-neutral-200 rounded-lg p-4">
|
||||||
{/* System Status Action Buttons at bottom */}
|
{/* System Status Action Buttons at bottom */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user