feat(redesign): re-skin admin-ops/platform-admin/public views to DS (Phase D batch 4 — panel re-skin complete)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

Final re-skin batch: admin ops (Console/FileManager[VueFinder preserved]/WipeCalendar/WipeHistory/Changelog/Migration), platform-admin (Dashboard/Licenses/Servers/Subscriptions/Users), public product pages (ServerInfo/StatusPage/StoreView) + PublicLayout, WarpEditor, ErrorBoundary. All logic/store/router/WebSocket/handlers preserved. Marketing views (Landing/Pricing/FAQ/HowItWorks/Roadmap/EarlyAccess + MarketingLayout) intentionally deferred to the dedicated marketing-site redesign. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 02:55:02 -04:00
parent 376ed9a98d
commit 29615cb4f3
17 changed files with 2843 additions and 1301 deletions

View File

@@ -2,8 +2,13 @@
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { Download, Upload, FileText, Loader2 } from 'lucide-vue-next'
import { safeFileSize, safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
interface ExportRecord {
id: string
@@ -20,6 +25,8 @@ const isExporting = ref(false)
const isImporting = ref(false)
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
const uploadFile = ref<File | null>(null)
const importError = ref<string | null>(null)
const importSuccess = ref(false)
async function fetchExports() {
exports.value = await api.get<ExportRecord[]>('/migration/exports')
@@ -49,6 +56,8 @@ async function importData() {
if (!confirm('Import data? This will overwrite existing configuration.')) return
isImporting.value = true
importError.value = null
importSuccess.value = false
try {
const formData = new FormData()
formData.append('file', uploadFile.value)
@@ -63,18 +72,20 @@ async function importData() {
throw new Error('Import failed')
}
alert('Import successful')
importSuccess.value = true
uploadFile.value = null
} catch (err) {
alert(err instanceof Error ? err.message : 'Import failed')
importError.value = err instanceof Error ? err.message : 'Import failed'
} finally {
isImporting.value = false
}
}
function formatBytes(bytes: number): string {
return safeFileSize(bytes)
}
const EXPORT_TYPE_OPTIONS = [
{ value: 'full' as const, label: 'Full' },
{ value: 'config_only' as const, label: 'Config only' },
{ value: 'store_only' as const, label: 'Store only' },
]
onMounted(() => {
fetchExports()
@@ -82,110 +93,251 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<FileText class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Migration</h1>
<div class="mv">
<!-- Page head -->
<div class="mv__head">
<div class="mv__head-id">
<div class="mv__head-chip">
<Icon name="upload" :size="20" :stroke-width="2" />
</div>
<div>
<div class="t-eyebrow">Platform</div>
<h1 class="mv__title">Migration</h1>
</div>
</div>
</div>
<!-- Export Section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Export Data</h2>
<div class="flex items-end gap-4">
<div>
<label class="block text-xs text-neutral-500 mb-2">Export Type</label>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<!-- Export section -->
<Panel title="Export data" subtitle="Create a portable backup of your configuration">
<div class="mv__export-row">
<div class="mv__export-field">
<div class="mv__field-label">Export type</div>
<div class="mv__seg">
<button
v-for="opt in (['full', 'config_only', 'store_only'] as const)"
:key="opt"
@click="exportType = opt"
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
:class="exportType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt.replace('_', ' ') }}
</button>
v-for="opt in EXPORT_TYPE_OPTIONS"
:key="opt.value"
type="button"
class="mv__seg-btn"
:class="exportType === opt.value && 'mv__seg-btn--active'"
@click="exportType = opt.value"
>{{ opt.label }}</button>
</div>
</div>
<button
@click="createExport"
<Button
icon="download"
:loading="isExporting"
:disabled="isExporting"
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
<Loader2 v-if="isExporting" class="w-4 h-4 animate-spin" />
<Download v-else class="w-4 h-4" />
Export
</button>
@click="createExport"
>Export</Button>
</div>
</div>
</Panel>
<!-- Export History -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div class="p-5 border-b border-neutral-800">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Export History</h2>
</div>
<div v-if="exports.length === 0" class="p-8 text-center text-neutral-500">
No exports yet.
</div>
<table v-else class="w-full">
<thead class="bg-neutral-800/50 border-b border-neutral-800">
<!-- Export history -->
<Panel :flush-body="true" title="Export history">
<EmptyState
v-if="exports.length === 0"
icon="file-text"
title="No exports yet"
description="Create an export above to see it listed here."
/>
<table v-else class="cc-table">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Created</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Size</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
<th>Type</th>
<th>Created</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-for="exp in exports" :key="exp.id" class="hover:bg-neutral-800/30">
<td class="px-4 py-3 text-sm text-neutral-200 capitalize">{{ exp.export_type.replace('_', ' ') }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(exp.created_at) }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatBytes(exp.file_size_bytes) }}</td>
<td class="px-4 py-3">
<tbody>
<tr v-for="exp in exports" :key="exp.id">
<td>
<Badge tone="neutral">{{ exp.export_type.replace('_', ' ') }}</Badge>
</td>
<td class="td-mono">{{ safeDate(exp.created_at) }}</td>
<td class="td-mono">{{ safeFileSize(exp.file_size_bytes) }}</td>
<td>
<a
v-if="exp.download_url"
:href="exp.download_url"
class="text-oxide-400 hover:text-oxide-300 text-sm transition-colors"
class="mv__dl-link"
>
<Icon name="download" :size="13" />
Download
</a>
<span v-else class="text-sm text-neutral-600">Preparing...</span>
<span v-else class="mv__preparing">Preparing</span>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
<!-- Import Section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Import Data</h2>
<div class="space-y-4">
<div class="border-2 border-dashed border-neutral-700 rounded-lg p-6 text-center">
<Upload class="w-8 h-8 text-neutral-500 mx-auto mb-2" />
<!-- Import section -->
<Panel title="Import data" subtitle="Restore from a JSON or ZIP export file">
<div class="mv__import-body">
<Alert v-if="importSuccess" tone="online">Import completed successfully.</Alert>
<Alert v-if="importError" tone="danger">{{ importError }}</Alert>
<label for="file-upload" class="mv__dropzone">
<Icon name="upload" :size="28" class="mv__dropzone-icon" />
<span class="mv__dropzone-label">
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
</span>
<span class="mv__dropzone-hint">JSON or ZIP exports</span>
<input
id="file-upload"
type="file"
accept=".json,.zip"
class="mv__file-input"
@change="handleFileSelect"
class="hidden"
id="file-upload"
/>
<label for="file-upload" class="cursor-pointer">
<span class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
</span>
</label>
<p class="text-xs text-neutral-500 mt-1">JSON or ZIP exports</p>
</div>
<button
@click="importData"
</label>
<Button
:block="true"
icon="upload"
:loading="isImporting"
:disabled="!uploadFile || isImporting"
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
<Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
<Upload v-else class="w-4 h-4" />
Import
</button>
@click="importData"
>Import</Button>
</div>
</div>
</Panel>
</div>
</template>
<style scoped>
.mv {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Page head */
.mv__head { display: flex; align-items: center; }
.mv__head-id { display: flex; align-items: center; gap: 12px; }
.mv__head-chip {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
color: var(--accent); background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.mv__title {
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 3px;
}
/* Export row */
.mv__export-row {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.mv__export-field { display: flex; flex-direction: column; gap: 6px; }
.mv__field-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-secondary);
}
/* Segment control (mirrors WipesView pattern) */
.mv__seg {
display: flex;
background: var(--surface-inset);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
overflow: hidden;
}
.mv__seg-btn {
height: var(--control-h-md);
padding: 0 14px;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: 0;
cursor: pointer;
transition: var(--transition-colors);
}
.mv__seg-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
.mv__seg-btn--active {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
/* Table */
.cc-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.cc-table thead tr {
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-inset);
}
.cc-table th {
padding: 10px 16px;
text-align: left;
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-tertiary);
white-space: nowrap;
}
.cc-table tbody tr {
border-bottom: 1px solid var(--border-subtle);
transition: var(--transition-colors);
}
.cc-table tbody tr:last-child { border-bottom: 0; }
.cc-table tbody tr:hover { background: var(--surface-hover); }
.cc-table td {
padding: 11px 16px;
color: var(--text-secondary);
vertical-align: middle;
}
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* Download link */
.mv__dl-link {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--text-sm);
font-weight: 500;
color: var(--accent-text);
text-decoration: none;
transition: var(--transition-colors);
}
.mv__dl-link:hover { color: var(--accent); }
.mv__preparing { font-size: var(--text-sm); color: var(--text-muted); }
/* Import body */
.mv__import-body {
display: flex;
flex-direction: column;
gap: 14px;
}
/* Dropzone */
.mv__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 32px 24px;
border: 1.5px dashed var(--border-default);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition-colors);
position: relative;
}
.mv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
.mv__dropzone-icon { color: var(--text-tertiary); }
.mv__dropzone:hover .mv__dropzone-icon { color: var(--accent-text); }
.mv__dropzone-label { font-size: var(--text-sm); font-weight: 500; color: var(--accent-text); }
.mv__dropzone-hint { font-size: var(--text-xs); color: var(--text-muted); }
.mv__file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
</style>