Multi-game rebrand (no more Rust-only leftovers): game-neutral setup wizard + deploy/store defaults; player-id labels driven by game profile (Steam ID only for Rust); blueprint wipe type + verify-plugins gated to uMod games; oxide command examples + Rust-only plugin pages (AutoDoors/FurnaceSplitter/BetterChat) guarded behind mods==='umod' with empty-states for other games. Honesty: webstore checkout shows coming-soon (backend now 503s); 'integrated webstore' marketed as coming-soon; Discord references neutralized to community/webhook; migration FAQ marked in-development; analytics dev phase labels removed; Network pricing tier set to Custom/Contact (was a confusing duplicate of Operator); docs/PRICING.md rewritten to match live subscriptions. UX/bugs: fixed ServerView oxide-status operator-precedence bug; dead 'Deploy server' button wired; non-functional topbar search removed; alert()/confirm() replaced with toasts across schedules/alerts/migration/public store+server; analytics chart arrays null-guarded; production console.logs gated to DEV. Frontend build (vue-tsc + vite) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
346 lines
9.8 KiB
Vue
346 lines
9.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useApi } from '@/composables/useApi'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToastStore } from '@/stores/toast'
|
|
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
|
|
export_type: 'full' | 'config_only' | 'store_only'
|
|
file_size_bytes: number
|
|
created_at: string
|
|
download_url?: string
|
|
}
|
|
|
|
const api = useApi()
|
|
const authStore = useAuthStore()
|
|
const toast = useToastStore()
|
|
const exports = ref<ExportRecord[]>([])
|
|
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')
|
|
}
|
|
|
|
async function createExport() {
|
|
if (!confirm(`Export ${exportType.value.replace('_', ' ')} data?`)) return
|
|
isExporting.value = true
|
|
try {
|
|
const result = await api.post<{ export_id: string }>('/migration/export', { export_type: exportType.value })
|
|
toast.success(`Export created: ${result.export_id}`)
|
|
await fetchExports()
|
|
} finally {
|
|
isExporting.value = false
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(event: Event) {
|
|
const target = event.target as HTMLInputElement
|
|
if (target.files && target.files.length > 0) {
|
|
uploadFile.value = target.files[0] ?? null
|
|
}
|
|
}
|
|
|
|
async function importData() {
|
|
if (!uploadFile.value) return
|
|
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)
|
|
|
|
const response = await fetch('/api/migration/import', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + authStore.accessToken },
|
|
body: formData,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Import failed')
|
|
}
|
|
|
|
importSuccess.value = true
|
|
uploadFile.value = null
|
|
} catch (err) {
|
|
importError.value = err instanceof Error ? err.message : 'Import failed'
|
|
} finally {
|
|
isImporting.value = false
|
|
}
|
|
}
|
|
|
|
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()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<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 -->
|
|
<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 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
|
|
icon="download"
|
|
:loading="isExporting"
|
|
:disabled="isExporting"
|
|
@click="createExport"
|
|
>Export</Button>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- 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>Type</th>
|
|
<th>Created</th>
|
|
<th>Size</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<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="mv__dl-link"
|
|
>
|
|
<Icon name="download" :size="13" />
|
|
Download
|
|
</a>
|
|
<span v-else class="mv__preparing">Preparing…</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Panel>
|
|
|
|
<!-- 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"
|
|
/>
|
|
</label>
|
|
|
|
<Button
|
|
:block="true"
|
|
icon="upload"
|
|
:loading="isImporting"
|
|
:disabled="!uploadFile || isImporting"
|
|
@click="importData"
|
|
>Import</Button>
|
|
</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>
|