update migrate
This commit is contained in:
@@ -3,21 +3,32 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminVideoRow = any;
|
||||
type ListVideosResponse = Awaited<ReturnType<typeof rpcClient.listAdminVideos>>;
|
||||
type AdminVideoRow = NonNullable<ListVideosResponse["videos"]>[number];
|
||||
|
||||
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"] as const;
|
||||
const statusFilterOptions = ["", ...statusOptions] as const;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminVideoRow[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(12);
|
||||
const page = ref(1);
|
||||
const selectedRow = ref<AdminVideoRow | null>(null);
|
||||
const search = ref("");
|
||||
const appliedSearch = ref("");
|
||||
const ownerFilter = ref("");
|
||||
const appliedOwnerFilter = ref("");
|
||||
const statusFilter = ref<(typeof statusFilterOptions)[number]>("");
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
@@ -44,6 +55,27 @@ const editForm = reactive({
|
||||
adTemplateId: "",
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.title.trim() && createForm.url.trim() && createForm.status.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.title.trim() && editForm.url.trim() && editForm.status.trim());
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
|
||||
const summary = computed(() => [
|
||||
{ label: "Visible videos", value: rows.value.length },
|
||||
{ label: "Ready assets", value: rows.value.filter((row) => row.status === "READY").length },
|
||||
{ label: "Processing", value: rows.value.filter((row) => row.status === "PROCESSING").length },
|
||||
{ label: "Total records", value: total.value },
|
||||
]);
|
||||
const selectedMeta = computed(() => {
|
||||
if (!selectedRow.value) return [];
|
||||
return [
|
||||
{ label: "Owner", value: selectedRow.value.ownerEmail || selectedRow.value.userId || "—" },
|
||||
{ label: "Status", value: selectedRow.value.status || "—" },
|
||||
{ label: "Format", value: selectedRow.value.format || "—" },
|
||||
{ label: "Size", value: formatBytes(selectedRow.value.size) },
|
||||
{ label: "Duration", value: formatDuration(selectedRow.value.duration) },
|
||||
{ label: "Updated", value: formatDate(selectedRow.value.updatedAt || selectedRow.value.createdAt) },
|
||||
];
|
||||
});
|
||||
|
||||
const normalizeOptional = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
@@ -51,9 +83,6 @@ const normalizeOptional = (value: string) => {
|
||||
|
||||
const normalizeNumber = (value: number | null) => (value == null ? undefined : value);
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.title.trim() && createForm.url.trim() && createForm.status.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.title.trim() && editForm.url.trim() && editForm.status.trim());
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.title = "";
|
||||
@@ -70,16 +99,31 @@ const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const syncSelectedRow = () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
};
|
||||
|
||||
const loadVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminVideos({ page: 1, limit: 20 });
|
||||
const response = await rpcClient.listAdminVideos({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
search: appliedSearch.value.trim() || undefined,
|
||||
userId: appliedOwnerFilter.value.trim() || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
});
|
||||
rows.value = response.videos ?? [];
|
||||
total.value = response.total ?? rows.value.length;
|
||||
limit.value = response.limit ?? limit.value;
|
||||
page.value = response.page ?? page.value;
|
||||
syncSelectedRow();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin videos";
|
||||
} finally {
|
||||
@@ -87,6 +131,13 @@ const loadVideos = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = async () => {
|
||||
page.value = 1;
|
||||
appliedSearch.value = search.value;
|
||||
appliedOwnerFilter.value = ownerFilter.value;
|
||||
await loadVideos();
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -153,7 +204,6 @@ const submitEdit = async () => {
|
||||
adTemplateId: normalizeOptional(editForm.adTemplateId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update video";
|
||||
@@ -170,6 +220,7 @@ const submitDelete = async () => {
|
||||
await rpcClient.deleteAdminVideo({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete video";
|
||||
@@ -178,58 +229,192 @@ const submitDelete = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const previousPage = async () => {
|
||||
if (page.value <= 1) return;
|
||||
page.value -= 1;
|
||||
await loadVideos();
|
||||
};
|
||||
|
||||
const nextPage = async () => {
|
||||
if (page.value >= totalPages.value) return;
|
||||
page.value += 1;
|
||||
await loadVideos();
|
||||
};
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatBytes = (value?: number) => {
|
||||
const bytes = Number(value || 0);
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const normalized = bytes / 1024 ** index;
|
||||
return `${normalized.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const formatDuration = (value?: number) => {
|
||||
const seconds = Number(value || 0);
|
||||
if (!seconds) return "—";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const statusBadgeClass = (status?: string) => {
|
||||
switch (status) {
|
||||
case "READY":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
case "PROCESSING":
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
case "FAILED":
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
default:
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
}
|
||||
};
|
||||
|
||||
watch(statusFilter, async () => {
|
||||
page.value = 1;
|
||||
await loadVideos();
|
||||
});
|
||||
|
||||
onMounted(loadVideos);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Videos"
|
||||
description="Cross-user video list from admin gRPC service."
|
||||
description="Cross-user video inventory with direct edit, moderation and storage context."
|
||||
eyebrow="Media"
|
||||
:badge="`${total} total videos`"
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" @click="loadVideos">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Title</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Size</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading videos...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No videos found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.title }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.format || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.size ?? 0 }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected video</div>
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ selectedRow.title }}</div>
|
||||
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.id }}</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">Source URL</div>
|
||||
<div class="mt-2 break-all text-sm text-slate-200">{{ selectedRow.url }}</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit video</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete video</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
|
||||
Select a video to review metadata, storage footprint and upstream source URL.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 xl:grid-cols-[minmax(0,1fr)_220px_180px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search by title" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Owner user ID</label>
|
||||
<AppInput v-model="ownerFilter" placeholder="Optional owner id" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</label>
|
||||
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; statusFilter = ''; loadVideos()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-slate-200">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">Video</th>
|
||||
<th class="px-4 py-3 font-semibold">Owner</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Format</th>
|
||||
<th class="px-4 py-3 font-semibold">Size</th>
|
||||
<th class="px-4 py-3 font-semibold">Duration</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading videos...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">No videos matched the current filters.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-t border-slate-200 transition-colors hover:bg-slate-50/70" :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''">
|
||||
<td class="px-4 py-3">
|
||||
<button class="text-left" @click="selectedRow = row">
|
||||
<div class="font-medium text-slate-900">{{ row.title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="statusBadgeClass(row.status)">
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.format || '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ formatBytes(row.size) }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ formatDuration(row.duration) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-slate-200 bg-slate-50/70 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-slate-500">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
|
||||
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user