Compare commits

..

2 Commits

2 changed files with 221 additions and 34 deletions

View File

@ -1,9 +1,30 @@
import { useEffect, useState } from 'react';
import { getFileContents } from '../webdav';
interface GeneralPageProps {
config: any;
setConfig: (config: any) => void;
}
export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
const [timezones, setTimezones] = useState<string[] | null>(null);
const [tzError, setTzError] = useState(false);
useEffect(() => {
getFileContents('/.sys/timezones.json')
.then(async (blob) => {
const parsed = JSON.parse(await blob.text());
if (Array.isArray(parsed)) {
setTimezones(parsed.map((e: any) => (typeof e === 'string' ? e : String(e.name ?? e.value ?? e))));
} else if (parsed && typeof parsed === 'object') {
setTimezones(Object.keys(parsed));
} else {
setTzError(true);
}
})
.catch(() => setTzError(true));
}, []);
const updateSetting = (path: string[], value: any) => {
const newConfig = JSON.parse(JSON.stringify(config));
let current = newConfig;
@ -51,13 +72,29 @@ export default function GeneralPage({ config, setConfig }: GeneralPageProps) {
<div className="p-4">
<label className="text-sm text-neutral-500 block mb-2">Timezone</label>
{!tzError && timezones ? (
<select
value={general.timezone || ''}
onChange={(e) => updateSetting(['general', 'timezone'], e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
>
{general.timezone && !timezones.includes(general.timezone) && (
<option value={general.timezone}>{general.timezone}</option>
)}
{timezones.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
) : (
<input
type="text"
value={general.timezone || ''}
onChange={(e) => updateSetting(['general', 'timezone'], e.target.value)}
placeholder="America/Los_Angeles"
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
placeholder={timezones === null && !tzError ? 'Loading…' : 'America/Los_Angeles'}
disabled={timezones === null && !tzError}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg disabled:opacity-50"
/>
)}
</div>
<div className="p-4">

View File

@ -1,12 +1,145 @@
import { Wrench, Download, Upload, RotateCcw, Database, FileText, HardDrive } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Wrench, Download, Upload, RotateCcw, Database, FileText, HardDrive, X, Loader2, Cpu, ChevronRight, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { getFileContents, putFileContents, humanFileSize } from '../webdav';
interface ToolsPageProps {
config: any;
setConfig: (config: any) => void;
}
interface FirmwareEntry {
version: string;
date: string;
description: string;
url: string;
size?: number;
}
function FirmwareOverlay({ onClose }: { onClose: () => void }) {
const [entries, setEntries] = useState<FirmwareEntry[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [installing, setInstalling] = useState<string | null>(null);
useEffect(() => {
getFileContents('/.sys/firmware.json')
.then(async (blob) => {
const parsed = JSON.parse(await blob.text());
if (Array.isArray(parsed)) {
setEntries(parsed);
} else {
setLoadError('Unexpected firmware manifest format.');
}
})
.catch((e: any) => setLoadError(e?.message || 'Failed to load firmware list.'));
}, []);
const install = async (entry: FirmwareEntry) => {
setInstalling(entry.url);
try {
let data: ArrayBuffer;
if (/^https?:\/\//.test(entry.url)) {
const r = await fetch(entry.url);
if (!r.ok) throw new Error(`Download failed: ${r.status} ${r.statusText}`);
data = await r.arrayBuffer();
} else {
const blob = await getFileContents(entry.url);
data = await blob.arrayBuffer();
}
await putFileContents('/sd/.bin', data);
toast.success(`Firmware ${entry.version} saved to /sd/.bin`);
onClose();
} catch (e: any) {
toast.error(`Install failed: ${e?.message || String(e)}`);
} finally {
setInstalling(null);
}
};
return (
<div className="fixed inset-0 bg-white z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
<div>
<h2 className="font-semibold text-lg">Change Firmware</h2>
<p className="text-xs text-neutral-500 mt-0.5">Selected firmware will be saved to /sd/.bin</p>
</div>
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{entries === null && !loadError && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-neutral-500">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="text-sm">Loading firmware list</span>
</div>
)}
{loadError && (
<div className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
<span>{loadError}</span>
</div>
)}
{entries && entries.length === 0 && (
<div className="text-center py-16 text-sm text-neutral-500">
No firmware updates available.
</div>
)}
{entries && entries.map((entry) => {
const isInstalling = installing === entry.url;
const busy = installing !== null;
return (
<div key={entry.url} className="border border-neutral-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="font-semibold text-neutral-900">{entry.version}</span>
<span className="text-xs text-neutral-400">{entry.date}</span>
{entry.size != null && (
<span className="text-xs text-neutral-400">· {humanFileSize(entry.size)}</span>
)}
</div>
<p className="text-sm text-neutral-600 leading-snug">{entry.description}</p>
</div>
<button
onClick={() => void install(entry)}
disabled={busy}
className={`flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition ${
isInstalling
? 'bg-blue-100 text-blue-600'
: busy
? 'bg-neutral-100 text-neutral-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isInstalling ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Installing
</>
) : (
<>
<Download className="w-3.5 h-3.5" />
Install
</>
)}
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
export default function ToolsPage({ config }: ToolsPageProps) {
const [showFirmware, setShowFirmware] = useState(false);
const handleBackup = () => {
const dataStr = JSON.stringify(config, null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
@ -140,32 +273,6 @@ export default function ToolsPage({ config }: ToolsPageProps) {
<h2 className="text-sm text-neutral-500 pt-4">System</h2>
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
<button
onClick={handleSystemUpdate}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Download className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">System Update</div>
<div className="text-sm text-neutral-500">Check for firmware updates</div>
</div>
</button>
<button
onClick={handleExportLogs}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-neutral-600" />
</div>
<div className="flex-1">
<div className="font-medium">Export System Logs</div>
<div className="text-sm text-neutral-500">Download diagnostic logs</div>
</div>
</button>
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
@ -190,7 +297,50 @@ export default function ToolsPage({ config }: ToolsPageProps) {
</div>
</div>
</div>
<button
onClick={handleSystemUpdate}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Download className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">System Update</div>
<div className="text-sm text-neutral-500">Check for firmware updates</div>
</div>
</button>
<button
onClick={() => setShowFirmware(true)}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Cpu className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<div className="font-medium">Change Firmware</div>
<div className="text-sm text-neutral-500">Select a firmware version to install</div>
</div>
<ChevronRight className="w-4 h-4 text-neutral-400" />
</button>
<button
onClick={handleExportLogs}
className="w-full p-4 flex items-center gap-3 hover:bg-neutral-50 text-left"
>
<div className="w-10 h-10 bg-neutral-100 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-neutral-600" />
</div>
<div className="flex-1">
<div className="font-medium">Export System Logs</div>
<div className="text-sm text-neutral-500">Download diagnostic logs</div>
</div>
</button>
</div>
{showFirmware && <FirmwareOverlay onClose={() => setShowFirmware(false)} />}
</div>
);
}