215 lines
7.3 KiB
Vue
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>
|