Mercury/ui/src/views/admin/Cdn.vue
Matthew L McPeak 2d95265e7d
Some checks failed
ci / build-ui (pull_request) Successful in 21s
ci / test (pull_request) Failing after 34s
ci / publish (pull_request) Has been skipped
fix: cleanup
2026-08-09 14:34:28 -04:00

215 lines
7.3 KiB
Vue

<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>CDN Objects</h2>
<span class="subtitle">Object storage registry keys proxied through the API to the local CDN</span>
</div>
<NychButton @click="showCreate = true">+ Add Object</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ objects.length }} {{ objects.length === 1 ? 'object' : 'objects' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
<template v-else-if="objects.length">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Key</th>
<th>Content Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in objects" :key="o.id">
<td><code>{{ o.key }}</code></td>
<td class="ct-cell">{{ o.content_type ?? '—' }}</td>
<td class="desc-cell">{{ o.description ?? '—' }}</td>
<td class="actions-cell">
<NychButton size="sm" @click="openEdit(o)">Edit</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingKey === o.key" @click="deleteObject(o.key)">Delete</NychButton>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div class="empty-state" v-else>
<span class="empty-icon">▣</span>
<span class="empty-label">No CDN objects</span>
<span class="empty-hint">Add objects to expose local CDN assets through the API.</span>
</div>
</div>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(560px,95vw)] sm:max-w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>Upload CDN Object</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>File</label>
<input type="file" ref="fileInput" @change="onFileChange" class="file-input" />
<p class="hint">The filename becomes the key unless overridden below.</p>
</div>
<div class="field">
<label>Key <span class="optional">(optional — defaults to filename)</span></label>
<NychInput v-model="form.key" placeholder="logo.png" class="w-full" />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional — auto-detected)</span></label>
<NychInput v-model="form.content_type" placeholder="image/png" class="w-full" />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="form.description" placeholder="App logo" class="w-full" />
</div>
<NychButton type="submit" class="w-full" :disabled="!selectedFile || creating" :loading="creating">Upload</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(560px,95vw)] sm:max-w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit — {{ editKey }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Key</label>
<NychInput v-model="editForm.key" class="w-full" />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.content_type" class="w-full" />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.description" class="w-full" />
</div>
<NychButton type="submit" class="w-full" :disabled="saving" :loading="saving">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const objects = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editKey = ref('')
const loading = ref(false)
const form = ref({ key: '', content_type: '', description: '' })
const editForm = ref({ key: '', content_type: '', description: '' })
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
function onFileChange() {
selectedFile.value = fileInput.value?.files?.[0] ?? null
}
async function load() {
loading.value = true
try {
const res = await fetch('/api/cdn', { headers: auth.authHeaders() })
objects.value = await res.json()
} finally {
loading.value = false
}
}
const creating = ref(false)
async function submitCreate() {
if (!selectedFile.value) return
creating.value = true
try {
const fd = new FormData()
fd.append('file', selectedFile.value)
if (form.value.key) fd.append('key', form.value.key)
if (form.value.content_type) fd.append('content_type', form.value.content_type)
if (form.value.description) fd.append('description', form.value.description)
const res = await fetch('/api/cdn/upload', {
method: 'POST',
headers: auth.authHeaders(),
body: fd,
})
if (!res.ok) return
showCreate.value = false
selectedFile.value = null
form.value = { key: '', content_type: '', description: '' }
await load()
} finally {
creating.value = false
}
}
function openEdit(o: any) {
editKey.value = o.key
editForm.value = {
key: o.key,
content_type: o.content_type ?? '',
description: o.description ?? '',
}
showEdit.value = true
}
const saving = ref(false)
async function submitEdit() {
saving.value = true
try {
const res = await fetch(`/api/cdn/${editKey.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
key: editForm.value.key || null,
content_type: editForm.value.content_type || null,
description: editForm.value.description || null,
}),
})
if (!res.ok) return
showEdit.value = false
await load()
} finally {
saving.value = false
}
}
const deletingKey = ref<string | null>(null)
async function deleteObject(key: string) {
if (!confirm(`Delete CDN object "${key}"?`)) return
deletingKey.value = key
try {
const res = await fetch(`/api/cdn/${key}`, { method: 'DELETE', headers: auth.authHeaders() })
if (!res.ok) return
await load()
} finally {
deletingKey.value = null
}
}
onMounted(load)
</script>
<style scoped>
.file-input {
width: 100%;
font-size: 0.85rem;
font-family: var(--font-sans);
color: var(--text-label);
cursor: pointer;
}
.ct-cell { color: var(--text-muted); font-size: 0.85rem; font-family: var(--font-mono); }
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
</style>