c6a7de895d
- JSON import/export with conflict resolution (skip/overwrite) + admin UI - Ping history sparklines on AppWidget and AppCard (24h, 288 points) - Hourly cleanup job for old AppStatus records - User theme preferences (hue, saturation, mode, background, locale) - Settings page with ThemeCustomizer (sliders, toggles, live preview) - Prisma migration for user preference fields - i18n translations for all new strings (EN/RU)
213 lines
6.6 KiB
Svelte
213 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
import { t } from 'svelte-i18n';
|
|
|
|
type ImportMode = 'skip' | 'overwrite';
|
|
|
|
let importMode: ImportMode = $state('skip');
|
|
let fileInput: HTMLInputElement | undefined = $state();
|
|
let previewData: string = $state('');
|
|
let parsedData: unknown = $state(null);
|
|
let importing = $state(false);
|
|
let exporting = $state(false);
|
|
let statusMessage = $state('');
|
|
let statusType: 'success' | 'error' | '' = $state('');
|
|
|
|
function clearStatus() {
|
|
statusMessage = '';
|
|
statusType = '';
|
|
}
|
|
|
|
async function handleExport() {
|
|
clearStatus();
|
|
exporting = true;
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/export');
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Export failed');
|
|
}
|
|
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const filenameMatch = disposition?.match(/filename="(.+)"/);
|
|
const filename = filenameMatch?.[1] ?? 'export.json';
|
|
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
statusMessage = $t('admin.export_success');
|
|
statusType = 'success';
|
|
} catch (err) {
|
|
statusMessage = err instanceof Error ? err.message : 'Export failed';
|
|
statusType = 'error';
|
|
} finally {
|
|
exporting = false;
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(event: Event) {
|
|
clearStatus();
|
|
const target = event.target as HTMLInputElement;
|
|
const file = target.files?.[0];
|
|
|
|
if (!file) {
|
|
previewData = '';
|
|
parsedData = null;
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const text = e.target?.result as string;
|
|
try {
|
|
const data = JSON.parse(text);
|
|
parsedData = data;
|
|
previewData = JSON.stringify(data, null, 2);
|
|
} catch {
|
|
previewData = '';
|
|
parsedData = null;
|
|
statusMessage = $t('admin.import_invalid_json');
|
|
statusType = 'error';
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (!parsedData) return;
|
|
|
|
clearStatus();
|
|
importing = true;
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ data: parsedData, mode: importMode })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok || !result.success) {
|
|
throw new Error(result.error || 'Import failed');
|
|
}
|
|
|
|
const d = result.data;
|
|
const parts: string[] = [];
|
|
if (d.apps.created > 0) parts.push(`Apps: +${d.apps.created}`);
|
|
if (d.apps.updated > 0) parts.push(`Apps updated: ${d.apps.updated}`);
|
|
if (d.apps.skipped > 0) parts.push(`Apps skipped: ${d.apps.skipped}`);
|
|
if (d.boards.created > 0) parts.push(`Boards: +${d.boards.created}`);
|
|
if (d.boards.updated > 0) parts.push(`Boards updated: ${d.boards.updated}`);
|
|
if (d.boards.skipped > 0) parts.push(`Boards skipped: ${d.boards.skipped}`);
|
|
if (d.groups.created > 0) parts.push(`Groups: +${d.groups.created}`);
|
|
if (d.groups.updated > 0) parts.push(`Groups updated: ${d.groups.updated}`);
|
|
if (d.groups.skipped > 0) parts.push(`Groups skipped: ${d.groups.skipped}`);
|
|
if (d.settingsUpdated) parts.push('Settings updated');
|
|
|
|
statusMessage = `${$t('admin.import_success')} ${parts.join(', ')}`;
|
|
statusType = 'success';
|
|
|
|
// Reset
|
|
previewData = '';
|
|
parsedData = null;
|
|
if (fileInput) fileInput.value = '';
|
|
} catch (err) {
|
|
statusMessage = err instanceof Error ? err.message : 'Import failed';
|
|
statusType = 'error';
|
|
} finally {
|
|
importing = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="rounded-lg border border-border bg-card p-6">
|
|
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.import_export_title')}</h2>
|
|
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.import_export_description')}</p>
|
|
|
|
<!-- Export -->
|
|
<div class="mb-6">
|
|
<h3 class="mb-2 text-sm font-medium text-foreground">{$t('admin.export_section')}</h3>
|
|
<button
|
|
type="button"
|
|
onclick={handleExport}
|
|
disabled={exporting}
|
|
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{exporting ? $t('admin.export_exporting') : $t('admin.export_button')}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="my-6 border-t border-border"></div>
|
|
|
|
<!-- Import -->
|
|
<div>
|
|
<h3 class="mb-2 text-sm font-medium text-foreground">{$t('admin.import_section')}</h3>
|
|
|
|
<!-- File input -->
|
|
<div class="mb-4">
|
|
<label for="import-file" class="mb-1 block text-sm text-muted-foreground">
|
|
{$t('admin.import_select_file')}
|
|
</label>
|
|
<input
|
|
bind:this={fileInput}
|
|
id="import-file"
|
|
type="file"
|
|
accept=".json,application/json"
|
|
onchange={handleFileSelect}
|
|
class="block w-full text-sm text-foreground file:mr-4 file:rounded-md file:border file:border-border file:bg-background file:px-4 file:py-2 file:text-sm file:font-medium file:text-foreground hover:file:bg-muted"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Preview -->
|
|
{#if previewData}
|
|
<div class="mb-4">
|
|
<label class="mb-1 block text-sm font-medium text-foreground">{$t('admin.import_preview')}</label>
|
|
<pre class="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">{previewData}</pre>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Mode selector -->
|
|
{#if parsedData}
|
|
<div class="mb-4">
|
|
<label for="import-mode" class="mb-1 block text-sm font-medium text-foreground">
|
|
{$t('admin.import_mode_label')}
|
|
</label>
|
|
<select
|
|
id="import-mode"
|
|
bind:value={importMode}
|
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
|
|
>
|
|
<option value="skip">{$t('admin.import_mode_skip')}</option>
|
|
<option value="overwrite">{$t('admin.import_mode_overwrite')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onclick={handleImport}
|
|
disabled={importing}
|
|
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{importing ? $t('admin.import_importing') : $t('admin.import_button')}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Status message -->
|
|
{#if statusMessage}
|
|
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
|
|
{statusMessage}
|
|
</div>
|
|
{/if}
|
|
</section>
|