feat: refactor billing plans section and remove unused components
- Updated BillingPlansSection.vue to clean up unused code and improve readability. - Removed CardPopover.vue and VideoGrid.vue components as they were no longer needed. - Enhanced VideoTable.vue by integrating BaseTable for better table management and added loading states. - Introduced secure JSON transformer for enhanced data security in RPC routes. - Added key resolver for managing server key pairs. - Created a script to generate NaCl keys for secure communications. - Implemented admin page header management for better UI consistency.
This commit is contained in:
@@ -6,12 +6,19 @@ const GET_PAYLOAD_PARAM = "payload";
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
JSON?: Partial<JsonTransformer>;
|
||||
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}): TinyRpcClientAdapter {
|
||||
const JSON: JsonTransformer = {
|
||||
parse: globalThis.JSON.parse,
|
||||
stringify: globalThis.JSON.stringify,
|
||||
...opts.JSON,
|
||||
};
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const payload = JSON.stringify(data.args);
|
||||
console.log("RPC Request:", payload);
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
|
||||
@@ -8,12 +8,19 @@ export const baseAPIURL = "https://api.pipic.fun";
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
JSON?: Partial<JsonTransformer>;
|
||||
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}): TinyRpcClientAdapter {
|
||||
const JSON: JsonTransformer = {
|
||||
parse: globalThis.JSON.parse,
|
||||
stringify: globalThis.JSON.stringify,
|
||||
...opts.JSON,
|
||||
};
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const payload = JSON.stringify(data.args);
|
||||
console.log("RPC Request:", payload);
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { RpcRoutes } from "@/server/routes/rpc";
|
||||
import { proxyTinyRpc } from "@hiogawa/tiny-rpc";
|
||||
import { httpClientAdapter } from "@httpClientAdapter";
|
||||
import type { RpcRoutes } from "@/server/routes/rpc";
|
||||
|
||||
const endpoint = "/rpc";
|
||||
const publicEndpoint = "/rpc-public";
|
||||
const url = import.meta.env.SSR ? "http://localhost" : "";
|
||||
const publicMethods = ["login", "register", "forgotPassword", "resetPassword", "getGoogleLoginUrl"];
|
||||
// src/client/trpc-client-transformer.ts
|
||||
import {
|
||||
clientJSON
|
||||
} from "@/shared/secure-json-transformer";
|
||||
|
||||
|
||||
// export function createTrpcClientTransformer(cfg: ServerPublicKeyConfig) {
|
||||
// return {
|
||||
// input: ,
|
||||
// output: superjson,
|
||||
// };
|
||||
// }
|
||||
// const secureConfig = await fetch("/trpc-secure-config").then((r) => r.json());
|
||||
export const client = proxyTinyRpc<RpcRoutes>({
|
||||
adapter: {
|
||||
send: async (data) => {
|
||||
@@ -14,6 +26,7 @@ export const client = proxyTinyRpc<RpcRoutes>({
|
||||
return await httpClientAdapter({
|
||||
url: `${url}${targetEndpoint}`,
|
||||
pathsForGET: ["health"],
|
||||
JSON: clientJSON,
|
||||
}).send(data);
|
||||
},
|
||||
},
|
||||
|
||||
153
src/components/ui/table/BaseTable.vue
Normal file
153
src/components/ui/table/BaseTable.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts" generic="TData extends Record<string, any>">
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnMeta,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
} from '@tanstack/vue-table';
|
||||
|
||||
type TableColumnMeta = ColumnMeta<TData, any> & {
|
||||
headerClass?: string;
|
||||
cellClass?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, any>[];
|
||||
loading?: boolean;
|
||||
emptyText?: string;
|
||||
tableClass?: string;
|
||||
wrapperClass?: string;
|
||||
headerRowClass?: string;
|
||||
bodyRowClass?: string | ((row: Row<TData>) => string | undefined);
|
||||
getRowId?: (originalRow: TData, index: number) => string;
|
||||
}>(), {
|
||||
loading: false,
|
||||
emptyText: 'No data available.',
|
||||
});
|
||||
|
||||
const sorting = ref<SortingState>([]);
|
||||
|
||||
function updateSorting(updaterOrValue: Updater<SortingState>) {
|
||||
sorting.value = typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(sorting.value)
|
||||
: updaterOrValue;
|
||||
}
|
||||
|
||||
const table = useVueTable<TData>({
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return props.columns;
|
||||
},
|
||||
getRowId: props.getRowId,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
},
|
||||
onSortingChange: updateSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
function resolveBodyRowClass(row: Row<TData>) {
|
||||
return typeof props.bodyRowClass === 'function'
|
||||
? props.bodyRowClass(row)
|
||||
: props.bodyRowClass;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('overflow-x-auto rounded-xl border border-gray-200 bg-white', wrapperClass)">
|
||||
<table :class="cn('w-full min-w-[48rem] border-collapse', tableClass)">
|
||||
<thead class="bg-header">
|
||||
<tr
|
||||
v-for="headerGroup in table.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
:class="cn('border-b border-gray-200', headerRowClass)"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="cn(
|
||||
'px-4 py-3 text-left text-sm font-medium text-gray-600',
|
||||
header.column.getCanSort() && !header.isPlaceholder && 'cursor-pointer select-none',
|
||||
(header.column.columnDef.meta as TableColumnMeta | undefined)?.headerClass
|
||||
)"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
<span
|
||||
v-if="header.column.getCanSort()"
|
||||
class="text-[10px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ header.column.getIsSorted() === 'asc' ? 'asc' : header.column.getIsSorted() === 'desc' ? 'desc' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td
|
||||
:colspan="columns.length || 1"
|
||||
class="px-4 py-10 text-center text-sm text-gray-500"
|
||||
>
|
||||
<slot name="loading">
|
||||
Loading...
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-else-if="!table.getRowModel().rows.length">
|
||||
<td
|
||||
:colspan="columns.length || 1"
|
||||
class="px-4 py-10 text-center text-sm text-gray-500"
|
||||
>
|
||||
<slot name="empty">
|
||||
{{ emptyText }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="row in table.getRowModel().rows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
:class="cn(
|
||||
'border-b border-gray-200 transition-colors last:border-b-0 hover:bg-gray-50',
|
||||
resolveBodyRowClass(row)
|
||||
)"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="cn(
|
||||
'px-4 py-3 align-middle',
|
||||
(cell.column.columnDef.meta as TableColumnMeta | undefined)?.cellClass
|
||||
)"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,13 @@ 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 BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListTemplatesResponse = Awaited<ReturnType<typeof rpcClient.listAdminAdTemplates>>;
|
||||
type AdminAdTemplateRow = NonNullable<ListTemplatesResponse["templates"]>[number];
|
||||
@@ -25,6 +30,7 @@ const appliedSearch = ref("");
|
||||
const ownerFilter = ref("");
|
||||
const appliedOwnerFilter = ref("");
|
||||
const createOpen = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
|
||||
@@ -86,7 +92,7 @@ const loadTemplates = async () => {
|
||||
total.value = response.total ?? rows.value.length;
|
||||
limit.value = response.limit ?? limit.value;
|
||||
page.value = response.page ?? page.value;
|
||||
if (selectedRow.value?.id) {
|
||||
if (selectedRow.value?.id && (detailOpen.value || editOpen.value || deleteOpen.value)) {
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
}
|
||||
@@ -110,6 +116,7 @@ const resetCreateForm = () => {
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
detailOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
actionError.value = null;
|
||||
@@ -122,6 +129,12 @@ const applyFilters = async () => {
|
||||
await loadTemplates();
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminAdTemplateRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminAdTemplateRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -228,128 +241,185 @@ const formatDate = (value?: string) => {
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<AdminAdTemplateRow>[]>(() => [
|
||||
{
|
||||
id: "template",
|
||||
header: "Template",
|
||||
accessorFn: row => row.name || "",
|
||||
cell: ({ row }) => h("button", { class: "text-left", onClick: () => { openDetailDialog(row.original); } }, [
|
||||
h("div", { class: "font-medium text-foreground" }, row.original.name),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.ownerEmail || row.original.userId || "No owner"),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "owner",
|
||||
header: "Owner",
|
||||
accessorFn: row => row.ownerEmail || row.userId || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.ownerEmail || row.original.userId || "—"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
header: "Format",
|
||||
accessorFn: row => row.adFormat || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.adFormat || "pre-roll"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessorFn: row => row.isActive ? "ACTIVE" : "INACTIVE",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.isActive ? "ACTIVE" : "INACTIVE"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
header: "Default",
|
||||
accessorFn: row => row.isDefault ? "YES" : "NO",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.isDefault ? "YES" : "NO"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openEditDialog(row.original) }, { default: () => "Edit" }),
|
||||
h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Advertising",
|
||||
badge: `${total.value} total templates`,
|
||||
actions: [
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
onClick: loadTemplates,
|
||||
},
|
||||
{
|
||||
label: "Create template",
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(loadTemplates);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Ad Templates"
|
||||
description="Operate VAST templates as reusable assets with quick filtering, defaults and owner context."
|
||||
eyebrow="Advertising"
|
||||
:badge="`${total} total templates`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" @click="loadTemplates">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create template</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected template</div>
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ selectedRow.name }}</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">VAST URL</div>
|
||||
<div class="mt-2 break-all text-sm text-slate-200">{{ selectedRow.vastTagUrl }}</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit template</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete template</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 template to inspect status, default flag and VAST source.
|
||||
</div>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</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_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 template name" @enter="applyFilters" />
|
||||
<SettingsSectionCard title="Filters" description="Search templates by name and narrow by owner reference if needed." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_220px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search template name" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Owner reference</label>
|
||||
<AppInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; loadTemplates()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</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="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; loadTemplates()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<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-if="error" class="rounded-lg 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">Template</th>
|
||||
<th class="px-4 py-3 font-semibold">Owner</th>
|
||||
<th class="px-4 py-3 font-semibold">Format</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Default</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="6" class="px-4 py-10 text-center text-slate-500">Loading ad templates...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-500">No templates 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.name }}</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 text-slate-700">{{ row.adFormat || 'pre-roll' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.isDefault ? 'YES' : 'NO' }}</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>
|
||||
<SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
|
||||
|
||||
<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>
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.name || ''"
|
||||
wrapperClass="border-x-0 border-t-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="mb-1 text-sm text-foreground/60">No templates matched the current filters.</p>
|
||||
<p class="text-xs text-foreground/40">Try a broader template name or clear the owner filter.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">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>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="detailOpen" title="Template details" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ selectedRow.name }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.ownerEmail || selectedRow.userId || 'No owner' }}</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">VAST URL</div>
|
||||
<div class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.vastTagUrl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
|
||||
<AppButton variant="danger" size="sm" @click="detailOpen = false; selectedRow && openDeleteDialog(selectedRow)">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create ad template" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
@@ -450,7 +520,7 @@ onMounted(loadTemplates);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete ad template <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
Delete ad template <span class="font-medium">{{ selectedRow?.name || 'this template' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue";
|
||||
import type { ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
|
||||
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
|
||||
@@ -15,6 +20,7 @@ const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminAgentRow[]>([]);
|
||||
const selectedRow = ref<AdminAgentRow | null>(null);
|
||||
const detailOpen = ref(false);
|
||||
const restartOpen = ref(false);
|
||||
const updateOpen = ref(false);
|
||||
let reloadAgentsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -22,7 +28,7 @@ let reloadAgentsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const summary = computed(() => [
|
||||
{ label: "Agents", value: rows.value.length },
|
||||
{ label: "Online", value: rows.value.filter((row) => matchesStatus(row.status, ["online", "active"])).length },
|
||||
{ label: "Busy", value: rows.value.reduce((sum, row) => sum + Number(row.activeJobCount ?? 0), 0) },
|
||||
{ label: "Busy", value: rows.value.reduce((sum, row) => sum + getActiveJobCount(row), 0) },
|
||||
{ label: "Total capacity", value: rows.value.reduce((sum, row) => sum + Number(row.capacity ?? 0), 0) },
|
||||
]);
|
||||
const selectedMeta = computed(() => {
|
||||
@@ -32,12 +38,13 @@ const selectedMeta = computed(() => {
|
||||
{ label: "Platform", value: selectedRow.value.platform || "—" },
|
||||
{ label: "Version", value: selectedRow.value.version || "—" },
|
||||
{ label: "Capacity", value: String(selectedRow.value.capacity ?? 0) },
|
||||
{ label: "Active jobs", value: String(selectedRow.value.activeJobCount ?? 0) },
|
||||
{ label: "Active jobs", value: String(getActiveJobCount(selectedRow.value)) },
|
||||
{ label: "Heartbeat", value: formatDate(selectedRow.value.lastHeartbeat) },
|
||||
];
|
||||
});
|
||||
|
||||
const matchesStatus = (value: string | undefined, candidates: string[]) => candidates.includes(String(value || "").toLowerCase());
|
||||
const getActiveJobCount = (row: AdminAgentRow) => Number((row as AdminAgentRow & { activeJobCount?: number; active_job_count?: number }).activeJobCount ?? (row as AdminAgentRow & { activeJobCount?: number; active_job_count?: number }).active_job_count ?? 0);
|
||||
|
||||
const loadAgents = async () => {
|
||||
loading.value = true;
|
||||
@@ -57,6 +64,7 @@ const loadAgents = async () => {
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
detailOpen.value = false;
|
||||
restartOpen.value = false;
|
||||
updateOpen.value = false;
|
||||
actionError.value = null;
|
||||
@@ -71,6 +79,12 @@ const scheduleAgentsReload = () => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminAgentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
};
|
||||
|
||||
const openRestartDialog = (row: AdminAgentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -127,9 +141,106 @@ const statusBadgeClass = (status?: string) => {
|
||||
if (["online", "active"].includes(normalized)) return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (["busy", "updating"].includes(normalized)) return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (["offline", "error", "failed"].includes(normalized)) return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
return "border-border bg-muted/40 text-foreground/70";
|
||||
};
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Workers",
|
||||
badge: `${rows.value.length} agents connected`,
|
||||
actions: [{
|
||||
label: "Refresh agents",
|
||||
variant: "secondary",
|
||||
onClick: loadAgents,
|
||||
}],
|
||||
}));
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Workers",
|
||||
badge: `${rows.value.length} agents connected`,
|
||||
actions: [{
|
||||
label: "Refresh agents",
|
||||
variant: "secondary",
|
||||
onClick: loadAgents,
|
||||
}],
|
||||
}));
|
||||
|
||||
const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
|
||||
{
|
||||
id: "agent",
|
||||
header: "Agent",
|
||||
accessorFn: row => row.name || row.id || "",
|
||||
cell: ({ row }) => h("button", { class: "text-left", onClick: () => { openDetailDialog(row.original); } }, [
|
||||
h("div", { class: "font-medium text-foreground" }, row.original.name || "Unnamed agent"),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.id),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, `${row.original.platform || "—"} · ${row.original.backend || "—"} · ${row.original.version || "—"}`),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "capacity",
|
||||
header: "Capacity",
|
||||
accessorFn: row => Number(row.capacity ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, String(row.original.capacity ?? 0)),
|
||||
meta: { headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50", cellClass: "px-4 py-3 text-right" },
|
||||
},
|
||||
{
|
||||
id: "activeJobCount",
|
||||
header: "Active jobs",
|
||||
accessorFn: row => getActiveJobCount(row),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, String(getActiveJobCount(row.original))),
|
||||
meta: { headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50", cellClass: "px-4 py-3 text-right" },
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
header: "CPU",
|
||||
accessorFn: row => Number(row.cpu ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, formatCpu(row.original.cpu)),
|
||||
meta: { headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50", cellClass: "px-4 py-3 text-right" },
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
header: "RAM",
|
||||
accessorFn: row => Number(row.ram ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, formatRam(row.original.ram)),
|
||||
meta: { headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50", cellClass: "px-4 py-3 text-right" },
|
||||
},
|
||||
{
|
||||
id: "heartbeat",
|
||||
header: "Heartbeat",
|
||||
accessorFn: row => row.lastHeartbeat || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/60" }, formatDate(row.original.lastHeartbeat)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openUpdateDialog(row.original) }, { default: () => "Update" }),
|
||||
h(AppButton, { size: "sm", variant: "danger", onClick: () => openRestartDialog(row.original) }, { default: () => "Restart" }),
|
||||
]),
|
||||
meta: { headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50", cellClass: "px-4 py-3 text-right" },
|
||||
},
|
||||
]);
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic !== "picpic/events") return;
|
||||
|
||||
@@ -171,120 +282,81 @@ onMounted(loadAgents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Agents"
|
||||
description="Watch worker health, capacity and maintenance actions while staying on the current admin runtime transport."
|
||||
eyebrow="Workers"
|
||||
:badge="`${rows.length} agents connected`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadAgents">Refresh agents</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected agent</div>
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ selectedRow.name || 'Unnamed agent' }}</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="grid grid-cols-2 gap-3">
|
||||
<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">CPU</div>
|
||||
<div class="mt-1 text-sm font-medium text-white">{{ formatCpu(selectedRow.cpu) }}</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">RAM</div>
|
||||
<div class="mt-1 text-sm font-medium text-white">{{ formatRam(selectedRow.ram) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<AppButton size="sm" @click="openUpdateDialog(selectedRow)">Update agent</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openRestartDialog(selectedRow)">Restart agent</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 an agent to inspect heartbeat, capacity and dispatch maintenance commands.
|
||||
</div>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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-if="error" class="rounded-lg 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">Agent</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Capacity</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Active jobs</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">CPU</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">RAM</th>
|
||||
<th class="px-4 py-3 font-semibold">Heartbeat</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="8" class="px-4 py-10 text-center text-slate-500">Loading agents...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="8" class="px-4 py-10 text-center text-slate-500">No agents connected.</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.name || 'Unnamed agent' }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.platform || '—' }} · {{ row.backend || '—' }} · {{ row.version || '—' }}</div>
|
||||
</button>
|
||||
</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 || 'UNKNOWN' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">{{ row.capacity ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">{{ row.activeJobCount ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">{{ formatCpu(row.cpu) }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">{{ formatRam(row.ram) }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.lastHeartbeat) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openUpdateDialog(row)">Update</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openRestartDialog(row)">Restart</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SettingsSectionCard v-else title="Agents" description="Connected workers and runtime health." bodyClass="">
|
||||
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.name || ''"
|
||||
wrapperClass="border-x-0 border-t-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="mb-1 text-sm text-foreground/60">No agents connected.</p>
|
||||
<p class="text-xs text-foreground/40">Workers will appear here when they register with the admin runtime.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="detailOpen" title="Agent details" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ selectedRow.name || 'Unnamed agent' }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.platform || 'Unknown platform' }} · {{ selectedRow.backend || 'Unknown backend' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">CPU</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ formatCpu(selectedRow.cpu) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">RAM</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ formatRam(selectedRow.ram) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openUpdateDialog(selectedRow)">Update</AppButton>
|
||||
<AppButton variant="danger" size="sm" @click="detailOpen = false; selectedRow && openRestartDialog(selectedRow)">Restart</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Send restart command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
Send restart command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -299,7 +371,7 @@ onMounted(loadAgents);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Send update command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
Send update command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -3,9 +3,14 @@ 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 BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListJobsResponse = Awaited<ReturnType<typeof rpcClient.listAdminJobs>>;
|
||||
type AdminJobRow = NonNullable<ListJobsResponse["jobs"]>[number];
|
||||
@@ -21,6 +26,7 @@ const activeAgentFilter = ref("");
|
||||
const appliedAgentFilter = ref("");
|
||||
const search = ref("");
|
||||
const createOpen = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const logsOpen = ref(false);
|
||||
const cancelOpen = ref(false);
|
||||
const retryOpen = ref(false);
|
||||
@@ -55,7 +61,7 @@ const filteredRows = computed(() => {
|
||||
const keyword = search.value.trim().toLowerCase();
|
||||
if (!keyword) return rows.value;
|
||||
return rows.value.filter((row) => {
|
||||
return [row.id, row.name, row.userId, row.agentId, row.status]
|
||||
return [row.name, row.status]
|
||||
.map((value) => String(value || "").toLowerCase())
|
||||
.some((value) => value.includes(keyword));
|
||||
});
|
||||
@@ -73,7 +79,7 @@ const selectedMeta = computed(() => {
|
||||
{ label: "Agent", value: selectedRow.value.agentId || "Unassigned" },
|
||||
{ label: "Priority", value: String(selectedRow.value.priority ?? 0) },
|
||||
{ label: "Progress", value: formatProgress(selectedRow.value.progress) },
|
||||
{ label: "User", value: selectedRow.value.userId || "—" },
|
||||
{ label: "Owner", value: selectedRow.value.userId || "—" },
|
||||
{ label: "Updated", value: formatDate(selectedRow.value.updatedAt) },
|
||||
];
|
||||
});
|
||||
@@ -98,6 +104,7 @@ const resetCreateForm = () => {
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
detailOpen.value = false;
|
||||
logsOpen.value = false;
|
||||
cancelOpen.value = false;
|
||||
retryOpen.value = false;
|
||||
@@ -107,7 +114,7 @@ const closeDialogs = () => {
|
||||
const syncSelectedRow = () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
if (fresh && (detailOpen.value || logsOpen.value || cancelOpen.value || retryOpen.value)) selectedRow.value = fresh;
|
||||
};
|
||||
|
||||
const loadJobs = async () => {
|
||||
@@ -138,9 +145,11 @@ const loadSelectedLogs = async (jobId: string) => {
|
||||
selectedLogs.value = response.logs || "No logs available.";
|
||||
};
|
||||
|
||||
const selectRow = async (row: AdminJobRow) => {
|
||||
const openDetailDialog = async (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
selectedLogs.value = "Loading logs...";
|
||||
detailOpen.value = true;
|
||||
try {
|
||||
await loadSelectedLogs(row.id);
|
||||
} catch {
|
||||
@@ -241,9 +250,95 @@ const statusBadgeClass = (status?: string) => {
|
||||
if (["running", "processing"].includes(normalized)) return "border-sky-200 bg-sky-50 text-sky-700";
|
||||
if (["pending", "queued"].includes(normalized)) return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (["failure", "failed", "cancelled"].includes(normalized)) return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
return "border-border bg-muted/40 text-foreground/70";
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<AdminJobRow>[]>(() => [
|
||||
{
|
||||
id: "job",
|
||||
header: "Job",
|
||||
accessorFn: row => row.name || row.id || "",
|
||||
cell: ({ row }) => h("button", { class: "text-left", onClick: () => { openDetailDialog(row.original); } }, [
|
||||
h("div", { class: "font-medium text-foreground" }, row.original.name || "Untitled job"),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.status || "Unknown status"),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
header: "Agent",
|
||||
accessorFn: row => row.agentId || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.agentId || "Unassigned"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
header: "Priority",
|
||||
accessorFn: row => Number(row.priority ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, String(row.original.priority ?? 0)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
header: "Progress",
|
||||
accessorFn: row => Number(row.progress ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, formatProgress(row.original.progress)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
accessorFn: row => row.updatedAt || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/60" }, formatDate(row.original.updatedAt)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openLogsDialog(row.original) }, { default: () => "Logs" }),
|
||||
...(isRetryable(row.original)
|
||||
? [h(AppButton, { size: "sm", onClick: () => openRetryDialog(row.original) }, { default: () => "Retry" })]
|
||||
: []),
|
||||
...(isCancelable(row.original)
|
||||
? [h(AppButton, { size: "sm", variant: "danger", onClick: () => openCancelDialog(row.original) }, { default: () => "Cancel" })]
|
||||
: []),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic.startsWith("picpic/job/") && payload?.type === "job_update") {
|
||||
const update = payload.payload;
|
||||
@@ -284,131 +379,113 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
}
|
||||
});
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Runtime",
|
||||
badge: `${rows.value.length} jobs loaded`,
|
||||
actions: [
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
onClick: loadJobs,
|
||||
},
|
||||
{
|
||||
label: "Create job",
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(loadJobs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Jobs"
|
||||
description="Queue visibility, live progress and operator interventions backed by the existing admin runtime contract."
|
||||
eyebrow="Runtime"
|
||||
:badge="`${rows.length} jobs loaded`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadJobs">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create job</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected job</div>
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ selectedRow.name || 'Untitled job' }}</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-slate-950/70 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-2 text-[11px] uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>Live logs</span>
|
||||
<button type="button" class="text-slate-300 transition hover:text-white" @click="openLogsDialog(selectedRow)">Expand</button>
|
||||
</div>
|
||||
<pre class="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words text-xs leading-5 text-emerald-300">{{ selectedLogs || 'No logs available.' }}</pre>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openLogsDialog(selectedRow)">Open full logs</AppButton>
|
||||
<AppButton v-if="isRetryable(selectedRow)" size="sm" @click="openRetryDialog(selectedRow)">Retry job</AppButton>
|
||||
<AppButton v-if="isCancelable(selectedRow)" size="sm" variant="danger" @click="openCancelDialog(selectedRow)">Cancel job</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 job to inspect runtime state and tail logs from the existing MQTT stream.
|
||||
</div>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</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-[220px_minmax(0,1fr)_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Agent filter</label>
|
||||
<AppInput v-model="activeAgentFilter" placeholder="Optional agent id" @enter="applyFilters" />
|
||||
<SettingsSectionCard title="Filters" description="Find jobs by name or status, then narrow the list by assigned agent if needed." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[220px_minmax(0,1fr)_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Assigned agent</label>
|
||||
<AppInput v-model="activeAgentFilter" placeholder="Optional agent reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search by job name or status" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="activeAgentFilter = ''; appliedAgentFilter = ''; search = ''; loadJobs()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<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 job id, name, user, agent" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="activeAgentFilter = ''; appliedAgentFilter = ''; search = ''; loadJobs()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<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-if="error" class="rounded-lg 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">Job</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Agent</th>
|
||||
<th class="px-4 py-3 font-semibold">Priority</th>
|
||||
<th class="px-4 py-3 font-semibold">Progress</th>
|
||||
<th class="px-4 py-3 font-semibold">Updated</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 jobs...</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredRows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">No jobs matched the current filters.</td>
|
||||
</tr>
|
||||
<tr v-for="row in filteredRows" :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="selectRow(row)">
|
||||
<div class="font-medium text-slate-900">{{ row.name || 'Untitled job' }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
|
||||
</button>
|
||||
</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 || 'UNKNOWN' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.agentId || 'Unassigned' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.priority ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ formatProgress(row.progress) }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.updatedAt) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openLogsDialog(row)">Logs</AppButton>
|
||||
<AppButton v-if="isRetryable(row)" size="sm" @click="openRetryDialog(row)">Retry</AppButton>
|
||||
<AppButton v-if="isCancelable(row)" size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSectionCard v-else title="Jobs" description="Current queue state and operator actions." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="filteredRows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.name || ''"
|
||||
wrapperClass="border-x-0 border-t-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="mb-1 text-sm text-foreground/60">No jobs matched the current filters.</p>
|
||||
<p class="text-xs text-foreground/40">Try a broader job name or clear the agent filter.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="detailOpen" title="Job details" maxWidthClass="max-w-3xl" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ selectedRow.name || 'Untitled job' }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.status || 'Unknown status' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-950 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-400">
|
||||
<span>Live logs</span>
|
||||
<button type="button" class="text-slate-300 transition hover:text-white" @click="selectedRow && openLogsDialog(selectedRow)">Open full logs</button>
|
||||
</div>
|
||||
<pre class="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words text-xs leading-5 text-emerald-300">{{ selectedLogs || 'No logs available.' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton v-if="selectedRow && isRetryable(selectedRow)" size="sm" @click="detailOpen = false; openRetryDialog(selectedRow)">Retry</AppButton>
|
||||
<AppButton v-if="selectedRow && isCancelable(selectedRow)" variant="danger" size="sm" @click="detailOpen = false; openCancelDialog(selectedRow)">Cancel</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create job" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
@@ -469,7 +546,7 @@ onMounted(loadJobs);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Cancel job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
Cancel <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -484,7 +561,7 @@ onMounted(loadJobs);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Retry job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
Retry <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -1,85 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import PageHeader from "@/components/dashboard/PageHeader.vue";
|
||||
import { computed, provide } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { adminPageHeaderKey, createAdminPageHeaderState } from "./components/useAdminPageHeader";
|
||||
|
||||
const route = useRoute();
|
||||
const pageHeader = createAdminPageHeaderState();
|
||||
|
||||
const sections = [
|
||||
{ to: "/admin/overview", label: "Overview", description: "KPIs, usage and runtime pulse" },
|
||||
{ to: "/admin/users", label: "Users", description: "Accounts, plans and moderation" },
|
||||
{ to: "/admin/videos", label: "Videos", description: "Cross-user media inventory" },
|
||||
{ to: "/admin/payments", label: "Payments", description: "Revenue, invoices and state changes" },
|
||||
{ to: "/admin/plans", label: "Plans", description: "Catalog and subscription offers" },
|
||||
{ to: "/admin/ad-templates", label: "Ad Templates", description: "VAST templates and defaults" },
|
||||
{ to: "/admin/jobs", label: "Jobs", description: "Queue, retries and live logs" },
|
||||
{ to: "/admin/agents", label: "Agents", description: "Workers, health and maintenance" },
|
||||
{ to: "/admin/logs", label: "Logs", description: "Direct runtime log lookup" },
|
||||
provide(adminPageHeaderKey, pageHeader);
|
||||
|
||||
const menuSections = [
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ to: "/admin/overview", label: "Overview", description: "KPIs, usage and runtime pulse" },
|
||||
{ to: "/admin/users", label: "Users", description: "Accounts, plans and moderation" },
|
||||
{ to: "/admin/videos", label: "Videos", description: "Cross-user media inventory" },
|
||||
{ to: "/admin/payments", label: "Payments", description: "Revenue, invoices and state changes" },
|
||||
{ to: "/admin/plans", label: "Plans", description: "Catalog and subscription offers" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Operations",
|
||||
items: [
|
||||
{ to: "/admin/ad-templates", label: "Ad Templates", description: "VAST templates and defaults" },
|
||||
{ to: "/admin/jobs", label: "Jobs", description: "Queue, retries and live logs" },
|
||||
{ to: "/admin/agents", label: "Agents", description: "Workers, health and maintenance" },
|
||||
{ to: "/admin/logs", label: "Logs", description: "Direct runtime log lookup" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const allSections = computed(() => menuSections.flatMap((section) => section.items));
|
||||
const activeSection = computed(() => {
|
||||
return sections.find((section) => route.path === section.to || route.path.startsWith(`${section.to}/`)) ?? sections[0];
|
||||
return allSections.value.find((section) => route.path === section.to || route.path.startsWith(`${section.to}/`)) ?? allSections.value[0];
|
||||
});
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: "Dashboard", to: "/overview" },
|
||||
{ label: "Admin", to: "/admin/overview" },
|
||||
...(activeSection.value ? [{ label: activeSection.value.label }] : []),
|
||||
]);
|
||||
|
||||
const content = computed(() => ({
|
||||
"admin-overview": {
|
||||
title: "Overview",
|
||||
subtitle: "KPIs, usage and runtime pulse across the admin workspace.",
|
||||
},
|
||||
"admin-users": {
|
||||
title: "Users",
|
||||
subtitle: "Accounts, plans and moderation tools for the full user base.",
|
||||
},
|
||||
"admin-videos": {
|
||||
title: "Videos",
|
||||
subtitle: "Cross-user media inventory, review and operational controls.",
|
||||
},
|
||||
"admin-payments": {
|
||||
title: "Payments",
|
||||
subtitle: "Revenue records, invoices and payment state operations.",
|
||||
},
|
||||
"admin-plans": {
|
||||
title: "Plans",
|
||||
subtitle: "Subscription catalog management and offer maintenance.",
|
||||
},
|
||||
"admin-ad-templates": {
|
||||
title: "Ad Templates",
|
||||
subtitle: "VAST templates, ownership metadata and default assignments.",
|
||||
},
|
||||
"admin-jobs": {
|
||||
title: "Jobs",
|
||||
subtitle: "Queue state, retries and runtime execution tracking.",
|
||||
},
|
||||
"admin-agents": {
|
||||
title: "Agents",
|
||||
subtitle: "Connected workers, health checks and maintenance actions.",
|
||||
},
|
||||
"admin-logs": {
|
||||
title: "Logs",
|
||||
subtitle: "Persisted output lookup and live runtime tailing.",
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<div class="overflow-hidden rounded-[28px] border border-slate-200 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_38%),linear-gradient(135deg,#020617,#0f172a_52%,#111827)] px-6 py-6 text-white shadow-[0_24px_80px_-40px_rgba(15,23,42,0.8)]">
|
||||
<div class="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div class="max-w-3xl space-y-3">
|
||||
<div class="inline-flex items-center rounded-full border border-white/15 bg-white/8 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100">
|
||||
Admin Console
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-white md:text-4xl">Operate the entire Stream workspace from one surface.</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-300 md:text-base">
|
||||
Screen coverage is aligned around the current Tiny-RPC + gRPC admin contract. Use the navigation below to jump between CRUD workflows, runtime operations and diagnostics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3 xl:min-w-[420px]">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur-sm">
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-400">Current module</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">{{ activeSection.label }}</div>
|
||||
<div class="mt-1 text-sm text-slate-300">{{ activeSection.description }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur-sm">
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-400">Coverage</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">{{ sections.length }} screens</div>
|
||||
<div class="mt-1 text-sm text-slate-300">Overview, CRUD, runtime and logs.</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur-sm">
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-400">Data path</div>
|
||||
<div class="mt-2 text-lg font-semibold text-white">Tiny-RPC</div>
|
||||
<div class="mt-1 text-sm text-slate-300">Canonical gRPC-backed admin transport.</div>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<div class="space-y-3">
|
||||
<div v-if="pageHeader.eyebrow || pageHeader.badge" class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="pageHeader.eyebrow" class="inline-flex items-center rounded-full border border-primary/15 bg-primary/8 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{{ pageHeader.eyebrow }}
|
||||
</span>
|
||||
<span v-if="pageHeader.badge" class="inline-flex items-center rounded-full border border-border bg-white px-2.5 py-1 text-[11px] font-medium text-foreground/60">
|
||||
{{ pageHeader.badge }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PageHeader
|
||||
:title="content[route.name as keyof typeof content]?.title || 'Workspace administration'"
|
||||
:description="content[route.name as keyof typeof content]?.subtitle || 'Quản lý dữ liệu, vận hành và chẩn đoán hệ thống theo cùng bố cục với khu settings.'"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
:actions="pageHeader.actions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside class="rounded-[28px] border border-slate-200 bg-white p-3 shadow-[0_20px_60px_-40px_rgba(15,23,42,0.35)]">
|
||||
<nav class="space-y-1">
|
||||
<router-link
|
||||
v-for="section in sections"
|
||||
:key="section.to"
|
||||
:to="section.to"
|
||||
class="group flex items-start gap-3 rounded-2xl px-4 py-3 transition-all duration-200"
|
||||
:class="route.path === section.to || route.path.startsWith(`${section.to}/`) ? 'bg-slate-950 text-white shadow-[0_18px_36px_-24px_rgba(15,23,42,0.8)]' : 'text-slate-700 hover:bg-slate-50'"
|
||||
>
|
||||
<div class="mt-0.5 h-2.5 w-2.5 rounded-full" :class="route.path === section.to || route.path.startsWith(`${section.to}/`) ? 'bg-sky-400' : 'bg-slate-300 group-hover:bg-slate-500'" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold tracking-tight">{{ section.label }}</div>
|
||||
<div class="mt-1 text-xs leading-5" :class="route.path === section.to || route.path.startsWith(`${section.to}/`) ? 'text-slate-300' : 'text-slate-500'">
|
||||
{{ section.description }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="max-w-7xl mx-auto pb-12">
|
||||
<div class="mt-6 flex flex-col gap-8 md:flex-row">
|
||||
<aside class="md:w-56 shrink-0">
|
||||
<div class="mb-8 rounded-lg border border-border bg-header px-4 py-4">
|
||||
<div class="text-sm font-semibold text-foreground">{{ activeSection?.label }}</div>
|
||||
<p class="mt-1 text-sm text-foreground/60">{{ activeSection?.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<router-view />
|
||||
<nav class="space-y-6">
|
||||
<div v-for="section in menuSections" :key="section.title">
|
||||
<h3 class="mb-2 pl-3 text-xs font-semibold uppercase tracking-wider text-foreground/50">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="item in section.items" :key="item.to">
|
||||
<router-link
|
||||
:to="item.to"
|
||||
:class="[
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all duration-150',
|
||||
route.path === item.to || route.path.startsWith(`${item.to}/`)
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-foreground/70 hover:bg-header hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 min-w-0">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -20,6 +22,8 @@ const summary = computed(() => [
|
||||
{ label: "Live lines", value: liveLineCount.value },
|
||||
]);
|
||||
|
||||
const activeChannel = computed(() => activeJobId.value ? `picpic/logs/${activeJobId.value}` : "No active stream");
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!jobId.value.trim()) return;
|
||||
loading.value = true;
|
||||
@@ -55,63 +59,61 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
liveLineCount.value += countLogLines(nextLine);
|
||||
}
|
||||
});
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Observability",
|
||||
badge: activeJobId.value ? "Live tail attached" : "Awaiting job selection",
|
||||
actions: [{
|
||||
label: "Load logs",
|
||||
variant: "secondary",
|
||||
onClick: loadLogs,
|
||||
}],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Logs"
|
||||
description="Fetch persisted output and continue tailing the selected job over the existing MQTT log stream."
|
||||
eyebrow="Observability"
|
||||
:badge="activeJobId ? 'Live tail attached' : 'Awaiting job selection'"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadLogs">Load logs</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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 truncate text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Tail status</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">Current channel</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-white">{{ activeJobId ? `picpic/logs/${activeJobId}` : 'No active stream' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
Persisted logs are loaded once from gRPC, then appended live from MQTT frames for the same job.
|
||||
</div>
|
||||
<AppButton size="sm" variant="secondary" @click="clearLogs">Clear session</AppButton>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 truncate text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 lg:flex-row lg:items-end">
|
||||
<div class="w-full max-w-xl space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Job ID</label>
|
||||
<AppInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
|
||||
<SettingsSectionCard title="Log session" description="Load persisted logs once, then keep appending live lines for the same job." bodyClass="p-5">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Job ID</label>
|
||||
<AppInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="ghost" @click="clearLogs">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadLogs">Fetch</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="ghost" @click="clearLogs">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadLogs">Fetch</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">
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Current channel</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-foreground">{{ activeChannel }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm leading-6 text-foreground/70">
|
||||
Persisted logs are loaded once from gRPC, then appended live from MQTT frames for the same job.
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-[24px] border border-slate-200 bg-slate-950 p-4 shadow-[0_12px_40px_-32px_rgba(15,23,42,0.6)]">
|
||||
<div class="mb-3 flex items-center justify-between gap-3 text-[11px] uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>Runtime output</span>
|
||||
<span>{{ activeJobId || 'idle' }}</span>
|
||||
<SettingsSectionCard title="Runtime output" :description="activeJobId || 'idle'" bodyClass="p-5">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-950 p-4">
|
||||
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre>
|
||||
</div>
|
||||
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type AdminDashboard = Awaited<ReturnType<typeof rpcClient.getAdminDashboard>>;
|
||||
|
||||
@@ -51,68 +52,61 @@ const loadDashboard = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Control room",
|
||||
badge: "Realtime-ready summary",
|
||||
actions: [{
|
||||
label: "Refresh metrics",
|
||||
variant: "secondary",
|
||||
onClick: loadDashboard,
|
||||
}],
|
||||
}));
|
||||
|
||||
onMounted(loadDashboard);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Overview"
|
||||
description="High-signal workspace metrics surfaced from the admin gRPC dashboard contract."
|
||||
eyebrow="Control room"
|
||||
badge="Realtime-ready summary"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadDashboard">
|
||||
Refresh metrics
|
||||
</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Operations notes</div>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div v-for="item in highlights" :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 leading-6 text-slate-200">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-for="card in cards" :key="card.title" class="rounded-[24px] border border-slate-200 bg-[linear-gradient(180deg,#ffffff,#f8fafc)] p-5 shadow-[0_12px_40px_-34px_rgba(15,23,42,0.45)]">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ card.title }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{{ loading ? '—' : card.value }}</div>
|
||||
<div class="mt-2 text-sm text-slate-500">{{ card.note }}</div>
|
||||
<div v-for="card in cards" :key="card.title" class="rounded-lg border border-border bg-muted/20 p-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ card.title }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold tracking-tight text-foreground">{{ loading ? '—' : card.value }}</div>
|
||||
<div class="mt-2 text-sm text-foreground/60">{{ card.note }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div class="rounded-[24px] border border-slate-200 bg-slate-50/70 p-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">System snapshot</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="card in secondaryCards" :key="card.title" class="rounded-2xl border border-slate-200 bg-white px-4 py-4">
|
||||
<div class="text-sm text-slate-500">{{ card.title }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ loading ? '—' : card.value }}</div>
|
||||
<SettingsSectionCard title="System snapshot" description="Core counters from the admin dashboard surface." bodyClass="p-5">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="card in secondaryCards" :key="card.title" class="rounded-lg border border-border bg-muted/20 px-4 py-4">
|
||||
<div class="text-sm text-foreground/60">{{ card.title }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ loading ? '—' : card.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<div class="rounded-[24px] border border-slate-200 bg-white p-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Dashboard source</div>
|
||||
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||
<p>This overview intentionally stays on top of the existing admin dashboard RPC instead of composing a new transport layer.</p>
|
||||
<p>Use module pages for operational actions, while this screen remains a concise summary surface for operators landing in the console.</p>
|
||||
<SettingsSectionCard title="Operations notes" description="Quick context for operators landing in the console." bodyClass="p-5">
|
||||
<div class="space-y-3">
|
||||
<div v-for="item in highlights" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm leading-6 text-foreground/70">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard title="Dashboard source" description="Why this page stays intentionally lightweight." bodyClass="p-5">
|
||||
<div class="space-y-3 text-sm leading-6 text-foreground/70">
|
||||
<p>This overview intentionally stays on top of the existing admin dashboard RPC instead of composing a new transport layer.</p>
|
||||
<p>Use module pages for operational actions, while this screen remains a concise summary surface for operators landing in the console.</p>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
|
||||
@@ -3,8 +3,15 @@ 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, watch } from "vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.vue";
|
||||
import type { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListPaymentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminPayments>>;
|
||||
type AdminPaymentRow = NonNullable<ListPaymentsResponse["payments"]>[number];
|
||||
@@ -14,10 +21,12 @@ const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"] as const;
|
||||
const statusFilterOptions = ["", ...statusOptions] as const;
|
||||
|
||||
const loading = ref(true);
|
||||
const plansLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPaymentRow[]>([]);
|
||||
const plans = ref<ModelPlan[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(12);
|
||||
const page = ref(1);
|
||||
@@ -26,6 +35,7 @@ const userFilter = ref("");
|
||||
const appliedUserFilter = ref("");
|
||||
const statusFilter = ref<(typeof statusFilterOptions)[number]>("");
|
||||
const createOpen = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const statusOpen = ref(false);
|
||||
|
||||
const createForm = reactive({
|
||||
@@ -44,6 +54,7 @@ const statusForm = reactive({
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.planId.trim() && createForm.termMonths >= 1 && createForm.paymentMethod.trim());
|
||||
const canUpdateStatus = computed(() => statusForm.id.trim() && statusForm.status.trim());
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
|
||||
const selectedPlanId = computed(() => createForm.planId || undefined);
|
||||
const summary = computed(() => [
|
||||
{ label: "Visible payments", value: rows.value.length },
|
||||
{ label: "Successful", value: rows.value.filter((row) => row.status === "SUCCESS").length },
|
||||
@@ -62,6 +73,16 @@ const selectedMeta = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const loadPlans = async () => {
|
||||
plansLoading.value = true;
|
||||
try {
|
||||
const response = await rpcClient.listPlans();
|
||||
plans.value = response.plans ?? [];
|
||||
} finally {
|
||||
plansLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPayments = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -76,7 +97,7 @@ const loadPayments = async () => {
|
||||
total.value = response.total ?? rows.value.length;
|
||||
limit.value = response.limit ?? limit.value;
|
||||
page.value = response.page ?? page.value;
|
||||
if (selectedRow.value?.id) {
|
||||
if (selectedRow.value?.id && (detailOpen.value || statusOpen.value)) {
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
}
|
||||
@@ -97,6 +118,7 @@ const resetCreateForm = () => {
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
detailOpen.value = false;
|
||||
statusOpen.value = false;
|
||||
actionError.value = null;
|
||||
};
|
||||
@@ -107,6 +129,12 @@ const applyFilters = async () => {
|
||||
await loadPayments();
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminPaymentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
};
|
||||
|
||||
const openStatusDialog = (row: AdminPaymentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -115,6 +143,10 @@ const openStatusDialog = (row: AdminPaymentRow) => {
|
||||
statusOpen.value = true;
|
||||
};
|
||||
|
||||
const selectPlan = (plan: ModelPlan) => {
|
||||
createForm.planId = plan.id || "";
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
@@ -175,6 +207,26 @@ const formatDate = (value?: string) => {
|
||||
|
||||
const formatMoney = (amount?: number, currency?: string) => `${amount ?? 0} ${currency || "USD"}`;
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
const value = Number(bytes || 0);
|
||||
if (!value) return "0 B";
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), sizes.length - 1);
|
||||
const normalized = value / 1024 ** index;
|
||||
return `${normalized.toFixed(index === 0 ? 0 : 1)} ${sizes[index]}`;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
const value = Number(seconds || 0);
|
||||
if (!value) return "0 min";
|
||||
if (value < 0) return "∞";
|
||||
return `${Math.floor(value / 60)} min`;
|
||||
};
|
||||
|
||||
const getPlanStorageText = (plan: ModelPlan) => `Storage ${formatBytes(plan.storageLimit || 0)}`;
|
||||
const getPlanDurationText = (plan: ModelPlan) => `Duration ${formatDuration(plan.durationLimit)}`;
|
||||
const getPlanUploadsText = (plan: ModelPlan) => `Uploads ${plan.uploadLimit || 0}`;
|
||||
|
||||
const statusBadgeClass = (status?: string) => {
|
||||
switch (status) {
|
||||
case "SUCCESS":
|
||||
@@ -185,151 +237,217 @@ const statusBadgeClass = (status?: string) => {
|
||||
case "CANCELLED":
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
default:
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
return "border-border bg-muted/40 text-foreground/70";
|
||||
}
|
||||
};
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Finance",
|
||||
badge: `${total.value} total payments`,
|
||||
actions: [
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
onClick: loadPayments,
|
||||
},
|
||||
{
|
||||
label: "Create payment",
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
|
||||
{
|
||||
id: "payment",
|
||||
header: "Payment",
|
||||
accessorFn: row => row.invoiceId || row.id || "",
|
||||
cell: ({ row }) => h("button", { class: "text-left", onClick: () => { openDetailDialog(row.original); } }, [
|
||||
h("div", { class: "font-medium text-foreground" }, formatMoney(row.original.amount, row.original.currency)),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.planName || row.original.planId || "No plan"),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
header: "User",
|
||||
accessorFn: row => row.userEmail || row.userId || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.userEmail || row.original.userId || "—"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
header: "Plan",
|
||||
accessorFn: row => row.planName || row.planId || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.planName || row.original.planId || "—"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
header: "Method",
|
||||
accessorFn: row => row.paymentMethod || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.paymentMethod || "—"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
accessorFn: row => row.createdAt || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/60" }, formatDate(row.original.createdAt)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openStatusDialog(row.original) }, { default: () => "Update status" }),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
watch(statusFilter, async () => {
|
||||
page.value = 1;
|
||||
await loadPayments();
|
||||
});
|
||||
|
||||
onMounted(loadPayments);
|
||||
onMounted(() => {
|
||||
void loadPlans();
|
||||
void loadPayments();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Payments"
|
||||
description="Track invoices, manual plan activations and state changes with a finance-focused operator view."
|
||||
eyebrow="Finance"
|
||||
:badge="`${total} total payments`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" @click="loadPayments">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create payment</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected payment</div>
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ formatMoney(selectedRow.amount, selectedRow.currency) }}</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>
|
||||
<AppButton size="sm" @click="openStatusDialog(selectedRow)">Update status</AppButton>
|
||||
</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 payment to review invoice metadata and push a status change.
|
||||
</div>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</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-[220px_220px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">User filter</label>
|
||||
<AppInput v-model="userFilter" placeholder="Optional user id" @enter="applyFilters" />
|
||||
<SettingsSectionCard title="Filters" description="Filter payments by user reference and status." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[220px_220px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">User reference</label>
|
||||
<AppInput v-model="userFilter" placeholder="Optional user reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Status</label>
|
||||
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-header 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="userFilter = ''; appliedUserFilter = ''; statusFilter = ''; loadPayments()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</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-header 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="userFilter = ''; appliedUserFilter = ''; statusFilter = ''; loadPayments()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<div v-if="error" class="rounded-lg 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">Payment</th>
|
||||
<th class="px-4 py-3 font-semibold">User</th>
|
||||
<th class="px-4 py-3 font-semibold">Plan</th>
|
||||
<th class="px-4 py-3 font-semibold">Method</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Created</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 payments...</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 payments 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">{{ formatMoney(row.amount, row.currency) }}</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.userEmail || row.userId }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.planName || row.planId || '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.paymentMethod || '—' }}</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-500">{{ formatDate(row.createdAt) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||
|
||||
<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>
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.invoiceId || ''"
|
||||
wrapperClass="border-x-0 border-t-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="mb-1 text-sm text-foreground/60">No payments matched the current filters.</p>
|
||||
<p class="text-xs text-foreground/40">Try a broader user reference or clear the status filter.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">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>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<AppDialog v-model:visible="detailOpen" title="Payment details" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ formatMoney(selectedRow.amount, selectedRow.currency) }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.planName || selectedRow.planId || 'No plan linked' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openStatusDialog(selectedRow)">Update status</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-4xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">User ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="createForm.planId" placeholder="plan-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Term months</label>
|
||||
<AppInput v-model="createForm.termMonths" type="number" min="1" />
|
||||
@@ -345,6 +463,25 @@ onMounted(loadPayments);
|
||||
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<BillingPlansSection
|
||||
title="Available plans"
|
||||
description="Reuse the same plan cards from the billing screen when creating an admin payment."
|
||||
:is-loading="plansLoading"
|
||||
:plans="plans"
|
||||
:current-plan-id="selectedPlanId"
|
||||
:selecting-plan-id="selectedPlanId"
|
||||
:format-money="(amount) => formatMoney(amount, 'USD')"
|
||||
:get-plan-storage-text="getPlanStorageText"
|
||||
:get-plan-duration-text="getPlanDurationText"
|
||||
:get-plan-uploads-text="getPlanUploadsText"
|
||||
current-plan-label="Selected"
|
||||
selecting-label="Selected"
|
||||
choose-label="Select plan"
|
||||
@select="selectPlan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
|
||||
@@ -3,8 +3,10 @@ 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 SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListPlansResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlans>>;
|
||||
type AdminPlanRow = NonNullable<ListPlansResponse["plans"]>[number];
|
||||
@@ -18,6 +20,7 @@ const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPlanRow[]>([]);
|
||||
const selectedRow = ref<AdminPlanRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
|
||||
@@ -75,7 +78,7 @@ const loadPlans = async () => {
|
||||
try {
|
||||
const response = await rpcClient.listAdminPlans();
|
||||
rows.value = response.plans ?? [];
|
||||
if (selectedRow.value?.id) {
|
||||
if (selectedRow.value?.id && (detailOpen.value || editOpen.value || deleteOpen.value)) {
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
}
|
||||
@@ -99,11 +102,18 @@ const resetCreateForm = () => {
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
detailOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminPlanRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminPlanRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -191,111 +201,123 @@ const submitDelete = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Catalog",
|
||||
badge: `${rows.value.length} plans loaded`,
|
||||
actions: [
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
onClick: loadPlans,
|
||||
},
|
||||
{
|
||||
label: "Create plan",
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(loadPlans);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Plans"
|
||||
description="Maintain the subscription catalog with a card-based overview and direct editing workflows."
|
||||
eyebrow="Catalog"
|
||||
:badge="`${rows.length} plans loaded`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" @click="loadPlans">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create plan</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected plan</div>
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ selectedRow.name }}</div>
|
||||
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.description || 'No description' }}</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">Features</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-200">
|
||||
<li v-for="feature in selectedRow.features || []" :key="feature">• {{ feature }}</li>
|
||||
<li v-if="!(selectedRow.features || []).length" class="text-slate-400">No features listed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit plan</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete plan</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">
|
||||
Choose a plan to inspect pricing, storage limits and feature bullets.
|
||||
</div>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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-if="loading" class="rounded-2xl border border-slate-200 px-4 py-10 text-center text-slate-500">Loading plans...</div>
|
||||
<div v-else-if="rows.length === 0" class="rounded-2xl border border-slate-200 px-4 py-10 text-center text-slate-500">No plans found.</div>
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||
<div v-else-if="loading" class="rounded-lg border border-border bg-muted/20 px-4 py-10 text-center text-foreground/60">Loading plans...</div>
|
||||
<div v-else-if="rows.length === 0" class="rounded-lg border border-border bg-muted/20 px-4 py-10 text-center text-foreground/60">No plans found.</div>
|
||||
<div v-else class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<button
|
||||
<SettingsSectionCard
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="rounded-[24px] border p-5 text-left transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_45px_-36px_rgba(15,23,42,0.45)]"
|
||||
:class="selectedRow?.id === row.id ? 'border-sky-300 bg-sky-50/70' : 'border-slate-200 bg-white'"
|
||||
@click="selectedRow = row"
|
||||
:title="row.name"
|
||||
:description="row.description || 'No description'"
|
||||
bodyClass="p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-lg font-semibold tracking-tight text-slate-950">{{ row.name }}</div>
|
||||
<div class="mt-1 text-sm text-slate-500">{{ row.description || 'No description' }}</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 flex-1 text-sm text-foreground/70">
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Price</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.price }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Cycle</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.cycle }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Storage</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.storageLimit }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Uploads</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.uploadLimit }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="row.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-border bg-muted/40 text-foreground/70'">
|
||||
{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="row.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-100 text-slate-700'">
|
||||
{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-2 gap-3 text-sm text-slate-700">
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Price</div>
|
||||
<div class="mt-1 font-semibold text-slate-950">{{ row.price }}</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Features</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-foreground/70">
|
||||
<li v-for="feature in row.features || []" :key="feature">• {{ feature }}</li>
|
||||
<li v-if="!(row.features || []).length" class="text-foreground/50">No features listed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Cycle</div>
|
||||
<div class="mt-1 font-semibold text-slate-950">{{ row.cycle }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Storage</div>
|
||||
<div class="mt-1 font-semibold text-slate-950">{{ row.storageLimit }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Uploads</div>
|
||||
<div class="mt-1 font-semibold text-slate-950">{{ row.uploadLimit }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click.stop="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click.stop="openDeleteDialog(row)">Delete</AppButton>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openDetailDialog(row)">Details</AppButton>
|
||||
<AppButton size="sm" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="detailOpen" title="Plan details" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ selectedRow.name }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.description || 'No description' }}</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Features</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-foreground/70">
|
||||
<li v-for="feature in selectedRow.features || []" :key="feature">• {{ feature }}</li>
|
||||
<li v-if="!(selectedRow.features || []).length" class="text-foreground/50">No features listed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
|
||||
<AppButton variant="danger" size="sm" @click="detailOpen = false; selectedRow && openDeleteDialog(selectedRow)">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin plan" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
@@ -396,7 +418,7 @@ onMounted(loadPlans);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || 'this plan' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -3,8 +3,13 @@ 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, watch } from "vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
|
||||
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
|
||||
@@ -26,6 +31,7 @@ const appliedSearch = ref("");
|
||||
const roleFilter = ref<(typeof roleFilterOptions)[number]>("");
|
||||
|
||||
const createOpen = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const roleOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
@@ -92,6 +98,7 @@ const resetCreateForm = () => {
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
detailOpen.value = false;
|
||||
editOpen.value = false;
|
||||
roleOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
@@ -101,7 +108,7 @@ const closeDialogs = () => {
|
||||
const syncSelectedRow = () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
if (fresh && (detailOpen.value || editOpen.value || roleOpen.value || deleteOpen.value)) selectedRow.value = fresh;
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
@@ -132,6 +139,12 @@ const applyFilters = async () => {
|
||||
await loadUsers();
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -258,9 +271,94 @@ const formatDate = (value?: string) => {
|
||||
const roleBadgeClass = (role?: string) => {
|
||||
const normalized = String(role || "USER").toUpperCase();
|
||||
if (normalized === "ADMIN") return "border-sky-200 bg-sky-50 text-sky-700";
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
return "border-border bg-muted/40 text-foreground/70";
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
|
||||
{
|
||||
id: "user",
|
||||
header: "User",
|
||||
accessorFn: (row) => row.email || row.id || "",
|
||||
cell: ({ row }) => h(
|
||||
"button",
|
||||
{
|
||||
class: "text-left",
|
||||
onClick: () => {
|
||||
openDetailDialog(row.original);
|
||||
},
|
||||
},
|
||||
[
|
||||
h("div", { class: "font-medium text-foreground" }, row.original.email || row.original.id || "—"),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.username ? `@${row.original.username}` : row.original.id || "—"),
|
||||
]
|
||||
),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
header: "Role",
|
||||
accessorFn: (row) => row.role || "USER",
|
||||
cell: ({ row }) => h(
|
||||
"span",
|
||||
{
|
||||
class: `inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${roleBadgeClass(row.original.role)}`,
|
||||
},
|
||||
row.original.role || "USER"
|
||||
),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
header: "Plan",
|
||||
accessorFn: (row) => row.planName || row.planId || "Free",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.planName || row.original.planId || "Free"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "videos",
|
||||
header: "Videos",
|
||||
accessorFn: (row) => row.videoCount ?? 0,
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, String(row.original.videoCount ?? 0)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
accessorFn: (row) => row.createdAt || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/60" }, formatDate(row.original.createdAt)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openEditDialog(row.original) }, { default: () => "Edit" }),
|
||||
h(AppButton, { size: "sm", variant: "ghost", onClick: () => openRoleDialog(row.original) }, { default: () => "Role" }),
|
||||
h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
watch(roleFilter, async () => {
|
||||
page.value = 1;
|
||||
await loadUsers();
|
||||
@@ -270,142 +368,77 @@ onMounted(loadUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Users"
|
||||
description="Manage account lifecycle, plan assignments and moderation from the current admin RPC contract."
|
||||
eyebrow="Identity"
|
||||
:badge="`${total} total users`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" @click="loadUsers">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create user</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4"
|
||||
class="rounded-lg border border-border bg-muted/20 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>
|
||||
|
||||
<template #aside>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected user</div>
|
||||
<div v-if="selectedRow" class="mt-3 space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">{{ selectedRow.email }}</div>
|
||||
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.username ? `@${selectedRow.username}` : 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="grid gap-2">
|
||||
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit profile</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="openRoleDialog(selectedRow)">Change role</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete user</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-3 rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
|
||||
Pick a row to inspect account metadata and trigger actions without leaving the list.
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px] lg:min-w-[560px]">
|
||||
<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 email or username" @enter="applyFilters" />
|
||||
<SettingsSectionCard title="Filters" description="Find users by email, username or role." bodyClass="p-5">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px] lg:min-w-[560px]">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.16em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search by email or username" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.16em] text-foreground/50">Role filter</label>
|
||||
<select v-model="roleFilter" class="w-full rounded-md border border-border bg-header 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="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Role filter</label>
|
||||
<select v-model="roleFilter" class="w-full rounded-md border border-border bg-header 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="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; appliedSearch = ''; roleFilter = ''; loadUsers()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; appliedSearch = ''; roleFilter = ''; loadUsers()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<div v-if="error" class="rounded-lg 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">User</th>
|
||||
<th class="px-4 py-3 font-semibold">Role</th>
|
||||
<th class="px-4 py-3 font-semibold">Plan</th>
|
||||
<th class="px-4 py-3 font-semibold">Videos</th>
|
||||
<th class="px-4 py-3 font-semibold">Created</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="6" class="px-4 py-10 text-center text-slate-500">Loading users...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-500">No users 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.email }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.username ? `@${row.username}` : row.id }}</div>
|
||||
</button>
|
||||
</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="roleBadgeClass(row.role)">
|
||||
{{ row.role || 'USER' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.planName || row.planId || 'Free' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.videoCount ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.createdAt) }}</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="ghost" @click="openRoleDialog(row)">Role</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
|
||||
|
||||
<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
|
||||
<template v-else>
|
||||
<BaseTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.email || ''"
|
||||
wrapperClass="border-x-0 border-t-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="mb-1 text-sm text-foreground/60">No users matched the current filters.</p>
|
||||
<p class="text-xs text-foreground/40">Try clearing the search term or switching the selected role.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">
|
||||
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 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>
|
||||
</template>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
@@ -445,6 +478,28 @@ onMounted(loadUsers);
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="detailOpen" title="User details" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ selectedRow.email }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.username ? `@${selectedRow.username}` : 'No username' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
|
||||
@@ -3,8 +3,13 @@ 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, watch } from "vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListVideosResponse = Awaited<ReturnType<typeof rpcClient.listAdminVideos>>;
|
||||
type AdminVideoRow = NonNullable<ListVideosResponse["videos"]>[number];
|
||||
@@ -27,6 +32,7 @@ const ownerFilter = ref("");
|
||||
const appliedOwnerFilter = ref("");
|
||||
const statusFilter = ref<(typeof statusFilterOptions)[number]>("");
|
||||
const createOpen = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
|
||||
@@ -97,6 +103,7 @@ const resetCreateForm = () => {
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
detailOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
actionError.value = null;
|
||||
@@ -105,7 +112,7 @@ const closeDialogs = () => {
|
||||
const syncSelectedRow = () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
|
||||
if (fresh) selectedRow.value = fresh;
|
||||
if (fresh && (detailOpen.value || editOpen.value || deleteOpen.value)) selectedRow.value = fresh;
|
||||
};
|
||||
|
||||
const loadVideos = async () => {
|
||||
@@ -138,6 +145,12 @@ const applyFilters = async () => {
|
||||
await loadVideos();
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -273,10 +286,110 @@ const statusBadgeClass = (status?: string) => {
|
||||
case "FAILED":
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
default:
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
return "border-border bg-muted/40 text-foreground/70";
|
||||
}
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
|
||||
{
|
||||
id: "video",
|
||||
header: "Video",
|
||||
accessorFn: row => row.title || "",
|
||||
cell: ({ row }) => h("button", { class: "text-left", onClick: () => { openDetailDialog(row.original); } }, [
|
||||
h("div", { class: "font-medium text-foreground" }, row.original.title),
|
||||
h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.ownerEmail || row.original.userId || "No owner"),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "owner",
|
||||
header: "Owner",
|
||||
accessorFn: row => row.ownerEmail || row.userId || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.ownerEmail || row.original.userId || "—"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
header: "Format",
|
||||
accessorFn: row => row.format || "",
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.format || "—"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
header: "Size",
|
||||
accessorFn: row => Number(row.size ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, formatBytes(row.original.size)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
header: "Duration",
|
||||
accessorFn: row => Number(row.duration ?? 0),
|
||||
cell: ({ row }) => h("span", { class: "text-foreground/70" }, formatDuration(row.original.duration)),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openEditDialog(row.original) }, { default: () => "Edit" }),
|
||||
h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
cellClass: "px-4 py-3 text-right",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Media",
|
||||
badge: `${total.value} total videos`,
|
||||
actions: [
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
onClick: loadVideos,
|
||||
},
|
||||
{
|
||||
label: "Create video",
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
watch(statusFilter, async () => {
|
||||
page.value = 1;
|
||||
await loadVideos();
|
||||
@@ -286,138 +399,101 @@ onMounted(loadVideos);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Videos"
|
||||
description="Cross-user video inventory with direct edit, moderation and storage context."
|
||||
eyebrow="Media"
|
||||
:badge="`${total} total videos`"
|
||||
>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" @click="loadVideos">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton>
|
||||
</template>
|
||||
<AdminSectionShell>
|
||||
|
||||
<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>
|
||||
|
||||
<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 v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</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" />
|
||||
<SettingsSectionCard title="Filters" description="Search videos by title and narrow by owner reference or status." bodyClass="p-5">
|
||||
<div class="grid gap-3 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-foreground/50">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-foreground/50">Owner reference</label>
|
||||
<AppInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Status</label>
|
||||
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-header 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 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-header 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>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<div v-if="error" class="rounded-lg 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>
|
||||
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||
|
||||
<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>
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.title || ''"
|
||||
wrapperClass="border-x-0 border-t-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="mb-1 text-sm text-foreground/60">No videos matched the current filters.</p>
|
||||
<p class="text-xs text-foreground/40">Try a broader title or clear the owner and status filters.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">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>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="detailOpen" title="Video details" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div v-if="selectedRow" class="space-y-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">{{ selectedRow.title }}</div>
|
||||
<div class="mt-1 text-sm text-foreground/60">{{ selectedRow.ownerEmail || selectedRow.userId || 'No owner' }}</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Source URL</div>
|
||||
<div class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
|
||||
<AppButton variant="danger" size="sm" @click="detailOpen = false; selectedRow && openDeleteDialog(selectedRow)">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin video" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
@@ -526,7 +602,7 @@ onMounted(loadVideos);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete video <span class="font-medium">{{ selectedRow?.title || selectedRow?.id }}</span>.
|
||||
Delete video <span class="font-medium">{{ selectedRow?.title || 'this video' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -1,27 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
columns: string[];
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { computed, h } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number | string[];
|
||||
rows?: number;
|
||||
}>();
|
||||
}>(), {
|
||||
columns: 3,
|
||||
rows: 4,
|
||||
});
|
||||
|
||||
type SkeletonRow = { id: string };
|
||||
|
||||
const data = computed<SkeletonRow[]>(() =>
|
||||
Array.from({ length: props.rows }, (_, index) => ({ id: `placeholder-${index}` }))
|
||||
);
|
||||
|
||||
const columnCount = computed(() => Array.isArray(props.columns) ? props.columns.length : props.columns);
|
||||
|
||||
const tableColumns = computed<ColumnDef<SkeletonRow>[]>(() =>
|
||||
Array.from({ length: columnCount.value }, (_, index) => ({
|
||||
id: `column-${index + 1}`,
|
||||
header: () => h('div', { class: 'h-3 w-20 rounded bg-muted/50 animate-pulse' }),
|
||||
cell: () => h('div', { class: 'space-y-2' }, [
|
||||
h('div', {
|
||||
class: [
|
||||
'h-4 rounded bg-muted/50',
|
||||
index + 1 === columnCount.value ? 'ml-auto w-16' : 'w-full max-w-[12rem]',
|
||||
],
|
||||
}),
|
||||
index === 0 ? h('div', { class: 'h-3 w-24 rounded bg-muted/40' }) : null,
|
||||
]),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClass: 'px-4 py-3',
|
||||
cellClass: 'px-4 py-4',
|
||||
},
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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 v-for="column in columns" :key="column" class="py-3 pr-4 font-medium">
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in rows ?? 5" :key="i" class="border-b border-gray-100">
|
||||
<td v-for="column in columns" :key="column" class="py-3 pr-4 text-gray-700">
|
||||
—
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<BaseTable
|
||||
:data="data"
|
||||
:columns="tableColumns"
|
||||
wrapperClass="border-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="animate-pulse border-b border-border hover:bg-transparent"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,47 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "@/components/dashboard/PageHeader.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
badge?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<div class="rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-5 shadow-[0_18px_60px_-36px_rgba(15,23,42,0.4)]">
|
||||
<div class="mb-5 flex flex-col gap-4 border-b border-slate-200/80 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span v-if="eyebrow" class="inline-flex items-center rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-700">
|
||||
{{ eyebrow }}
|
||||
</span>
|
||||
<span v-if="badge" class="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-600 shadow-sm">
|
||||
{{ badge }}
|
||||
</span>
|
||||
</div>
|
||||
<PageHeader :title="title" :description="description" />
|
||||
</div>
|
||||
<section class="space-y-6">
|
||||
<div v-if="$slots.stats" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.toolbar" class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.stats" class="mb-5 grid gap-3 border-b border-slate-200/80 pb-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
|
||||
<div :class="$slots.aside ? 'grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]' : ''">
|
||||
<div class="min-w-0 rounded-[24px] border border-slate-200/80 bg-white/90 p-5 shadow-[0_12px_40px_-32px_rgba(15,23,42,0.45)]">
|
||||
<slot />
|
||||
</div>
|
||||
<aside v-if="$slots.aside" class="min-w-0 rounded-[24px] border border-slate-200/80 bg-slate-950 p-5 text-slate-100 shadow-[0_18px_50px_-30px_rgba(2,6,23,0.8)]">
|
||||
<slot name="aside" />
|
||||
</aside>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
52
src/routes/admin/components/useAdminPageHeader.ts
Normal file
52
src/routes/admin/components/useAdminPageHeader.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { inject, onBeforeUnmount, reactive, watchEffect, type VNode } from 'vue';
|
||||
|
||||
export type AdminHeaderAction = {
|
||||
label: string;
|
||||
icon?: string | VNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type AdminPageHeaderState = {
|
||||
eyebrow?: string;
|
||||
badge?: string;
|
||||
actions: AdminHeaderAction[];
|
||||
owner: symbol | null;
|
||||
};
|
||||
|
||||
export const adminPageHeaderKey = Symbol('admin-page-header');
|
||||
|
||||
export const createAdminPageHeaderState = (): AdminPageHeaderState => reactive({
|
||||
eyebrow: undefined,
|
||||
badge: undefined,
|
||||
actions: [],
|
||||
owner: null,
|
||||
});
|
||||
|
||||
export const useAdminPageHeader = (getConfig: () => Omit<AdminPageHeaderState, 'owner'>) => {
|
||||
const state = inject<AdminPageHeaderState | null>(adminPageHeaderKey, null);
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = Symbol('admin-page-header-owner');
|
||||
|
||||
watchEffect(() => {
|
||||
const config = getConfig();
|
||||
state.owner = owner;
|
||||
state.eyebrow = config.eyebrow;
|
||||
state.badge = config.badge;
|
||||
state.actions = config.actions;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (state.owner !== owner) return;
|
||||
state.owner = null;
|
||||
state.eyebrow = undefined;
|
||||
state.badge = undefined;
|
||||
state.actions = [];
|
||||
});
|
||||
};
|
||||
@@ -1,137 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
videos: ModelVideo[];
|
||||
loading: boolean;
|
||||
videos: ModelVideo[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const uiState = useUIState();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<ModelVideo>[]>(() => [
|
||||
{
|
||||
id: 'video',
|
||||
header: t('overview.recentVideos.table.video'),
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-3' }, [
|
||||
h('div', { class: 'h-12 w-20 flex-shrink-0 overflow-hidden rounded bg-gray-200' }, row.original.thumbnail
|
||||
? h('img', {
|
||||
src: row.original.thumbnail,
|
||||
alt: row.original.title,
|
||||
class: 'h-full w-full object-cover',
|
||||
})
|
||||
: h('div', { class: 'flex h-full w-full items-center justify-center' }, [
|
||||
h('span', { class: 'i-heroicons-film text-xl text-gray-400' }),
|
||||
])),
|
||||
h('div', { class: 'min-w-0 flex-1' }, [
|
||||
h('p', { class: 'truncate font-medium text-gray-900' }, row.original.title),
|
||||
h('p', { class: 'truncate text-sm text-gray-500' }, row.original.description || t('overview.recentVideos.noDescription')),
|
||||
]),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('overview.recentVideos.table.status'),
|
||||
accessorFn: row => row.status || '',
|
||||
cell: ({ row }) => h('span', {
|
||||
class: ['whitespace-nowrap rounded-full px-2 py-1 text-xs font-medium', getStatusClass(row.original.status)],
|
||||
}, row.original.status || t('overview.recentVideos.unknownStatus')),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
header: t('overview.recentVideos.table.duration'),
|
||||
accessorFn: row => Number(row.duration || 0),
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-gray-500' }, formatDuration(row.original.duration)),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: t('overview.recentVideos.table.uploadDate'),
|
||||
accessorFn: row => row.createdAt || '',
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-gray-500' }, formatDate(row.original.createdAt)),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('overview.recentVideos.table.actions'),
|
||||
enableSorting: false,
|
||||
cell: () => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('button', {
|
||||
class: 'rounded p-1.5 transition-colors hover:bg-gray-100',
|
||||
title: t('overview.recentVideos.actionEdit'),
|
||||
}, [h('span', { class: 'i-heroicons-pencil h-4 w-4 text-gray-600' })]),
|
||||
h('button', {
|
||||
class: 'rounded p-1.5 transition-colors hover:bg-gray-100',
|
||||
title: t('overview.recentVideos.actionShare'),
|
||||
}, [h('span', { class: 'i-heroicons-share h-4 w-4 text-gray-600' })]),
|
||||
h('button', {
|
||||
class: 'rounded p-1.5 transition-colors hover:bg-red-100',
|
||||
title: t('overview.recentVideos.actionDelete'),
|
||||
}, [h('span', { class: 'i-heroicons-trash h-4 w-4 text-red-600' })]),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-8">
|
||||
<div v-if="loading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-32 h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||
<div class="flex gap-4">
|
||||
<div class="w-16 h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="w-[30%] h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-[20%] h-3 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
||||
<router-link to="/videos"
|
||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
|
||||
{{ t('overview.recentVideos.viewAll') }}
|
||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="videos.length === 0" :title="t('overview.recentVideos.emptyTitle')"
|
||||
:description="t('overview.recentVideos.emptyDescription')"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('overview.recentVideos.emptyAction')"
|
||||
:onAction="() => uiState.toggleUploadDialog()" />
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.video') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.status') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.duration') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.uploadDate') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="video in videos" :key="video.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{{ video.description || t('overview.recentVideos.noDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
|
||||
{{ video.status || t('overview.recentVideos.unknownStatus') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.createdAt) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" :title="t('overview.recentVideos.actionEdit')">
|
||||
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" :title="t('overview.recentVideos.actionShare')">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" :title="t('overview.recentVideos.actionDelete')">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<div v-if="loading">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="h-6 w-32 rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-4 w-20 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div v-for="i in 5" :key="i" class="border-b border-gray-200 p-4 last:border-b-0">
|
||||
<div class="flex gap-4">
|
||||
<div class="h-10 w-16 rounded bg-gray-200 animate-pulse" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-[30%] rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-3 w-[20%] rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
||||
<router-link to="/videos" class="flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
{{ t('overview.recentVideos.viewAll') }}
|
||||
<span class="i-heroicons-arrow-right h-4 w-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="videos.length === 0"
|
||||
:title="t('overview.recentVideos.emptyTitle')"
|
||||
:description="t('overview.recentVideos.emptyDescription')"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png"
|
||||
:actionLabel="t('overview.recentVideos.emptyAction')"
|
||||
:onAction="() => uiState.toggleUploadDialog()"
|
||||
/>
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="props.videos"
|
||||
:columns="columns"
|
||||
:get-row-id="(row, index) => row.id || `recent-video-${index}`"
|
||||
wrapperClass="rounded-xl border border-gray-200 bg-white"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-gray-50 border-b border-gray-200"
|
||||
bodyRowClass="hover:bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
@@ -16,6 +17,7 @@ import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCar
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
@@ -378,6 +380,121 @@ const getAdFormatColor = (format: string) => {
|
||||
};
|
||||
return colors[format] || 'bg-gray-500/10 text-gray-500';
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<VastTemplate>[]>(() => [
|
||||
{
|
||||
id: 'template',
|
||||
header: t('settings.adsVast.table.template'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', [
|
||||
h('div', { class: 'flex flex-wrap items-center gap-2' }, [
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
row.original.isDefault
|
||||
? h('span', {
|
||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary',
|
||||
}, t('settings.adsVast.defaultBadge'))
|
||||
: null,
|
||||
]),
|
||||
h('p', { class: 'mt-0.5 text-xs text-foreground/50' }, t('settings.adsVast.createdOn', { date: row.original.createdAt || '-' })),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'format',
|
||||
header: t('settings.adsVast.table.format'),
|
||||
accessorFn: row => row.adFormat,
|
||||
cell: ({ row }) => h('div', [
|
||||
h('span', {
|
||||
class: ['rounded-full px-2 py-1 text-xs font-medium', getAdFormatColor(row.original.adFormat)],
|
||||
}, getAdFormatLabel(row.original.adFormat)),
|
||||
row.original.adFormat === 'mid-roll' && row.original.duration
|
||||
? h('span', { class: 'ml-2 text-xs text-foreground/50' }, `(${row.original.duration}s)`)
|
||||
: null,
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vastUrl',
|
||||
header: t('settings.adsVast.table.vastUrl'),
|
||||
accessorFn: row => row.vastUrl,
|
||||
cell: ({ row }) => h('div', { class: 'flex max-w-[240px] items-center gap-2' }, [
|
||||
h('code', { class: 'truncate text-xs text-foreground/60' }, row.original.vastUrl),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: isMutating.value,
|
||||
onClick: () => copyToClipboard(row.original.vastUrl),
|
||||
}, {
|
||||
icon: () => h(CheckIcon, { class: 'h-4 w-4' }),
|
||||
}),
|
||||
]),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('common.status'),
|
||||
accessorFn: row => Number(row.enabled),
|
||||
cell: ({ row }) => h('div', { class: 'text-center' }, [
|
||||
h(AppSwitch, {
|
||||
modelValue: row.original.enabled,
|
||||
disabled: isFreePlan.value || saving.value || deletingId.value !== null || defaultingId.value !== null || togglingId.value === row.original.id,
|
||||
'onUpdate:modelValue': (value: boolean) => handleToggle(row.original, value),
|
||||
}),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h('div', { class: 'flex flex-wrap items-center justify-end gap-2' }, [
|
||||
row.original.isDefault
|
||||
? h('span', {
|
||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary',
|
||||
}, t('settings.adsVast.actions.default'))
|
||||
: h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
loading: defaultingId.value === row.original.id,
|
||||
disabled: isFreePlan.value || saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null || !row.original.enabled,
|
||||
onClick: () => handleSetDefault(row.original),
|
||||
}, () => t('settings.adsVast.actions.setDefault')),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: isFreePlan.value || isMutating.value,
|
||||
onClick: () => openEditDialog(row.original),
|
||||
}, {
|
||||
icon: () => h(PencilIcon, { class: 'h-4 w-4' }),
|
||||
}),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: isFreePlan.value || isMutating.value,
|
||||
onClick: () => handleDelete(row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -411,105 +528,24 @@ const getAdFormatColor = (format: string) => {
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.template') }}</th>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.format') }}</th>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.vastUrl') }}</th>
|
||||
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.status') }}</th>
|
||||
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<template v-if="templates.length > 0">
|
||||
<tr
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
|
||||
<span
|
||||
v-if="template.isDefault"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ t('settings.adsVast.defaultBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt || '-' }) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
|
||||
{{ getAdFormatLabel(template.adFormat) }}
|
||||
</span>
|
||||
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
|
||||
({{ template.duration }}s)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2 max-w-[240px]">
|
||||
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isMutating" @click="copyToClipboard(template.vastUrl)">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-center">
|
||||
<AppSwitch
|
||||
:model-value="template.enabled"
|
||||
:disabled="isFreePlan || saving || deletingId !== null || defaultingId !== null || togglingId === template.id"
|
||||
@update:model-value="handleToggle(template, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="template.isDefault"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ t('settings.adsVast.actions.default') }}
|
||||
</span>
|
||||
<AppButton
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:loading="defaultingId === template.id"
|
||||
:disabled="isFreePlan || saving || deletingId !== null || togglingId !== null || defaultingId !== null || !template.enabled"
|
||||
@click="handleSetDefault(template)"
|
||||
>
|
||||
{{ t('settings.adsVast.actions.setDefault') }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isFreePlan || isMutating" @click="openEditDialog(template)">
|
||||
<template #icon>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isFreePlan || isMutating" @click="handleDelete(template)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else>
|
||||
<td colspan="5" class="px-6 py-12 text-center">
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.adsVast.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.adsVast.emptySubtitle') }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="templates"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.adsVast.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.adsVast.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
@@ -13,6 +14,7 @@ import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
@@ -220,6 +222,49 @@ const copyIframeCode = async () => {
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<DomainItem>[]>(() => [
|
||||
{
|
||||
id: 'domain',
|
||||
header: t('settings.domainsDns.table.domain'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(LinkIcon, { class: 'h-4 w-4 text-foreground/40' }),
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'addedAt',
|
||||
header: t('settings.domainsDns.table.addedDate'),
|
||||
accessorFn: row => row.addedAt,
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-foreground/60' }, row.original.addedAt),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: adding.value || removingId.value !== null,
|
||||
onClick: () => handleRemoveDomain(row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -243,53 +288,24 @@ const copyIframeCode = async () => {
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.domainsDns.table.domain') }}</th>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.domainsDns.table.addedDate') }}</th>
|
||||
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<template v-if="domains.length > 0">
|
||||
<tr
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="adding || removingId !== null"
|
||||
@click="handleRemoveDomain(domain)"
|
||||
>
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else>
|
||||
<td colspan="3" class="px-6 py-12 text-center">
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="domains"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
@@ -6,35 +9,42 @@ const props = withDefaults(defineProps<{
|
||||
columns: 3,
|
||||
rows: 4,
|
||||
});
|
||||
|
||||
type SkeletonRow = { id: string };
|
||||
|
||||
const data = computed<SkeletonRow[]>(() =>
|
||||
Array.from({ length: props.rows }, (_, index) => ({ id: `row-${index}` }))
|
||||
);
|
||||
|
||||
const tableColumns = computed<ColumnDef<SkeletonRow>[]>(() =>
|
||||
Array.from({ length: props.columns }, (_, index) => ({
|
||||
id: `column-${index + 1}`,
|
||||
header: () => h('div', { class: 'h-3 w-20 rounded bg-muted/50 animate-pulse' }),
|
||||
cell: () => h('div', { class: 'space-y-2' }, [
|
||||
h('div', {
|
||||
class: [
|
||||
'h-4 rounded bg-muted/50',
|
||||
index + 1 === props.columns ? 'ml-auto w-16' : 'w-full max-w-[12rem]',
|
||||
],
|
||||
}),
|
||||
index === 0 ? h('div', { class: 'h-3 w-24 rounded bg-muted/40' }) : null,
|
||||
]),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="px-6 py-3"
|
||||
>
|
||||
<div class="h-3 w-20 rounded bg-muted/50 animate-pulse" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="row in rows" :key="row" class="animate-pulse">
|
||||
<td v-for="column in columns" :key="column" class="px-6 py-4">
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 rounded bg-muted/50" :class="column === columns ? 'ml-auto w-16' : 'w-full max-w-[12rem]'" />
|
||||
<div
|
||||
v-if="column === 1"
|
||||
class="h-3 w-24 rounded bg-muted/40"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<BaseTable
|
||||
:data="data"
|
||||
:columns="tableColumns"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="animate-pulse hover:bg-transparent"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -69,18 +69,6 @@ const emit = defineEmits<{
|
||||
<span class="text-foreground/60 text-sm"> / {{ $t('settings.billing.cycle.'+plan.cycle) }}</span>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-4 text-sm">
|
||||
<!-- <li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanStorageText(plan) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanDurationText(plan) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanUploadsText(plan) }}
|
||||
</li> -->
|
||||
<li
|
||||
v-for="feature in plan.features || []"
|
||||
:key="feature"
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button type="button" class="p-1.5 rounded-md hover:bg-gray-100 transition-colors" @click="toggle"
|
||||
aria-haspopup="true" :aria-expanded="isOpen">
|
||||
<EllipsisVerticalIcon class="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-40" @click="isOpen = false" />
|
||||
<Transition enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||
<div v-if="isOpen" ref="menuRef"
|
||||
class="fixed z-50 min-w-[160px] bg-white rounded-lg border border-gray-200 shadow-lg py-1"
|
||||
:style="menuStyle">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div v-if="item.separator" class="h-px bg-gray-200 my-1" />
|
||||
<router-link v-else-if="item.route" :to="item.route"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
|
||||
@click="isOpen = false">
|
||||
<component :is="item.icon" class="w-4 h-4" :class="item.iconClass" />
|
||||
<span :class="item.labelClass">{{ item.label }}</span>
|
||||
</router-link>
|
||||
<button v-else type="button"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm w-full text-left"
|
||||
@click="item.command?.(); isOpen = false">
|
||||
<component :is="item.icon" class="w-4 h-4" :class="item.iconClass" />
|
||||
<span :class="item.labelClass">{{ item.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DefineComponent } from 'vue';
|
||||
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import EllipsisVerticalIcon from '@/components/icons/EllipsisVerticalIcon.vue';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
const props = defineProps<{
|
||||
video: ModelVideo
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void;
|
||||
}>();
|
||||
|
||||
const toast = useAppToast();
|
||||
const isOpen = ref(false);
|
||||
const containerRef = ref<HTMLElement>();
|
||||
const menuRef = ref<HTMLElement>();
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const videoUrl = computed(() => {
|
||||
return `${window.location.origin}/videos/${props.video.id}`;
|
||||
});
|
||||
|
||||
const toggle = async () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value) {
|
||||
await nextTick();
|
||||
positionMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const positionMenu = () => {
|
||||
if (!containerRef.value) return;
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
menuStyle.value = {
|
||||
top: `${rect.bottom + 4}px`,
|
||||
left: `${rect.right}px`,
|
||||
transform: 'translateX(-100%)',
|
||||
};
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('video.cardPopover.toast.copySuccessSummary'),
|
||||
detail: t('video.cardPopover.toast.copySuccessDetail'),
|
||||
life: 3000
|
||||
});
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.cardPopover.toast.copyErrorSummary'),
|
||||
detail: t('video.cardPopover.toast.copyErrorDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (props.video.id) {
|
||||
const link = document.createElement('a');
|
||||
link.href = props.video.url?.startsWith('http') ? props.video.url : videoUrl.value;
|
||||
link.download = props.video.title || 'video';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('video.cardPopover.toast.downloadSuccessSummary'),
|
||||
detail: t('video.cardPopover.toast.downloadSuccessDetail'),
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.cardPopover.toast.downloadErrorSummary'),
|
||||
detail: t('video.cardPopover.toast.downloadErrorDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
interface CustomMenuItem {
|
||||
label?: string;
|
||||
icon?: DefineComponent<{}, {}, any>;
|
||||
iconClass?: string;
|
||||
labelClass?: string;
|
||||
separator?: boolean;
|
||||
route?: RouteLocationRaw;
|
||||
command?: () => void;
|
||||
}
|
||||
|
||||
const items = computed<CustomMenuItem[]>(() => [
|
||||
{
|
||||
label: t('video.cardPopover.download'),
|
||||
icon: ArrowDownTray,
|
||||
command: handleDownload
|
||||
},
|
||||
{
|
||||
label: t('video.cardPopover.copyLink'),
|
||||
icon: LinkIcon,
|
||||
command: handleCopyLink
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('video.cardPopover.edit'),
|
||||
icon: PencilIcon,
|
||||
route: { name: 'video-detail', params: { id: props.video.id } }
|
||||
},
|
||||
{
|
||||
label: t('video.cardPopover.delete'),
|
||||
icon: TrashIcon,
|
||||
iconClass: 'text-red-500',
|
||||
labelClass: 'text-red-500',
|
||||
command: handleDelete
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import CardPopover from './CardPopover.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||
(e: 'delete', videoId: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const severityClasses: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
warn: 'bg-yellow-100 text-yellow-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const isSelected = (video: ModelVideo) =>
|
||||
props.selectedVideos.some(v => v.id === video.id);
|
||||
|
||||
const toggleSelection = (video: ModelVideo) => {
|
||||
if (isSelected(video)) {
|
||||
emit('update:selectedVideos', props.selectedVideos.filter(v => v.id !== video.id));
|
||||
} else {
|
||||
emit('update:selectedVideos', [...props.selectedVideos, video]);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div v-if="loading" v-for="i in 10" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div class="w-full h-[150px] bg-gray-200 animate-pulse" />
|
||||
<div class="p-4">
|
||||
<div class="w-4/5 h-6 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
<div class="w-3/5 h-4 bg-gray-200 rounded animate-pulse mb-4" />
|
||||
<div class="flex justify-between">
|
||||
<div class="w-12 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-12 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="video in videos" :key="video.id" v-else
|
||||
class="bg-white overflow-hidden transition group relative border-2 border-gray-200 rounded-xl !shadow-none"
|
||||
:class="{ '!border-primary ring-2 ring-primary': isSelected(video) }">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
|
||||
<!-- Grid Selection Checkbox -->
|
||||
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="{ 'opacity-100': isSelected(video) }">
|
||||
<input type="checkbox" :checked="isSelected(video)" @change="toggleSelection(video)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
|
||||
</div>
|
||||
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<span class="i-heroicons-film text-3xl" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
|
||||
:title="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<button class="text-gray-400 hover:text-gray-700">
|
||||
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || t('video.table.noDescription') }}
|
||||
</p>
|
||||
<div class="text-xs text-gray-400 mt-auto">
|
||||
{{ formatDate(video.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 pb-4 mt-auto flex items-center justify-between">
|
||||
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
|
||||
{{ video.status }}
|
||||
</span>
|
||||
<CardPopover :video="video" @delete="emit('delete', video.id || '')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,145 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
||||
import { formatBytes, formatDate, getStatusSeverity } from '@/lib/utils';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
loading: boolean;
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||
(e: 'delete', videoId: string): void;
|
||||
(e: 'edit', videoId: string): void;
|
||||
(e: 'copy', videoId: string): void;
|
||||
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||
(e: 'delete', videoId: string): void;
|
||||
(e: 'edit', videoId: string): void;
|
||||
(e: 'copy', videoId: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const severityClasses: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
warn: 'bg-yellow-100 text-yellow-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
warn: 'bg-yellow-100 text-yellow-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const isAllSelected = computed(() =>
|
||||
props.videos.length > 0 && props.selectedVideos.length === props.videos.length
|
||||
props.videos.length > 0 && props.selectedVideos.length === props.videos.length
|
||||
);
|
||||
|
||||
const toggleAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
emit('update:selectedVideos', []);
|
||||
} else {
|
||||
emit('update:selectedVideos', [...props.videos]);
|
||||
}
|
||||
if (isAllSelected.value) {
|
||||
emit('update:selectedVideos', []);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:selectedVideos', [...props.videos]);
|
||||
};
|
||||
|
||||
const toggleRow = (video: ModelVideo) => {
|
||||
const exists = props.selectedVideos.some(v => v.id === video.id);
|
||||
if (exists) {
|
||||
emit('update:selectedVideos', props.selectedVideos.filter(v => v.id !== video.id));
|
||||
} else {
|
||||
emit('update:selectedVideos', [...props.selectedVideos, video]);
|
||||
}
|
||||
const exists = props.selectedVideos.some(v => v.id === video.id);
|
||||
if (exists) {
|
||||
emit('update:selectedVideos', props.selectedVideos.filter(v => v.id !== video.id));
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:selectedVideos', [...props.selectedVideos, video]);
|
||||
};
|
||||
|
||||
const isSelected = (video: ModelVideo) =>
|
||||
props.selectedVideos.some(v => v.id === video.id);
|
||||
props.selectedVideos.some(v => v.id === video.id);
|
||||
|
||||
const columns = computed<ColumnDef<ModelVideo>[]>(() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: () => h('input', {
|
||||
type: 'checkbox',
|
||||
checked: isAllSelected.value,
|
||||
onChange: toggleAll,
|
||||
class: 'h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary',
|
||||
}),
|
||||
cell: ({ row }) => h('input', {
|
||||
type: 'checkbox',
|
||||
checked: isSelected(row.original),
|
||||
onChange: () => toggleRow(row.original),
|
||||
class: 'h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary',
|
||||
}),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClass: 'w-12',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
header: t('video.table.video'),
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-3' }, [
|
||||
h('div', { class: 'h-12 w-20 flex-shrink-0 overflow-hidden rounded bg-gray-200' }, row.original.thumbnail
|
||||
? h('img', {
|
||||
src: row.original.thumbnail,
|
||||
alt: row.original.title,
|
||||
class: 'h-full w-full object-cover',
|
||||
})
|
||||
: h('div', { class: 'flex h-full w-full items-center justify-center' }, [
|
||||
h(VideoIcon, { class: 'h-5 w-5 text-gray-400' }),
|
||||
])),
|
||||
h('div', { class: 'min-w-0 flex-1' }, [
|
||||
h('p', { class: 'truncate font-medium text-gray-900' }, row.original.title),
|
||||
h('p', { class: 'truncate text-sm text-gray-500' }, row.original.description || t('video.table.noDescription')),
|
||||
]),
|
||||
]),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('video.table.status'),
|
||||
cell: ({ row }) => h('span', {
|
||||
class: [
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium capitalize',
|
||||
severityClasses[getStatusSeverity(row.original.status) || 'secondary'],
|
||||
],
|
||||
}, row.original.status),
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
header: t('video.table.size'),
|
||||
accessorFn: row => Number(row.size || 0),
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-gray-500' }, formatBytes(row.original.size)),
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: t('video.table.created'),
|
||||
accessorFn: row => row.createdAt || '',
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-gray-500' }, formatDate(row.original.createdAt, true)),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('video.table.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-0.5' }, [
|
||||
h('button', {
|
||||
class: 'rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700',
|
||||
title: t('video.table.copyLink'),
|
||||
onClick: () => emit('copy', row.original.id!),
|
||||
}, [h(LinkIcon, { class: 'h-4 w-4' })]),
|
||||
h('button', {
|
||||
class: 'rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary',
|
||||
title: t('video.table.edit'),
|
||||
onClick: () => emit('edit', row.original.id!),
|
||||
}, [h(PencilIcon, { class: 'h-4 w-4' })]),
|
||||
h('button', {
|
||||
class: 'rounded-md p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-500',
|
||||
title: t('video.table.delete'),
|
||||
onClick: () => emit('delete', row.original.id!),
|
||||
}, [h(TrashIcon, { class: 'h-4 w-4' })]),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div v-if="loading">
|
||||
<div class="p-4 border-b border-gray-200 last:border-b-0" v-for="i in 10" :key="i">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded-md animate-pulse" />
|
||||
<div class="flex-1">
|
||||
<div class="w-2/5 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
<div class="w-1/4 h-3 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="w-[8%] h-3 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-[8%] h-3 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-16 h-6 bg-gray-200 rounded-full animate-pulse" />
|
||||
<div class="w-22 h-7 bg-gray-200 rounded-md animate-pulse" />
|
||||
</div>
|
||||
<BaseTable
|
||||
:data="videos"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:get-row-id="(row, index) => row.id || `${row.title || 'video'}-${index}`"
|
||||
tableClass="min-w-[50rem]"
|
||||
:body-row-class="(row) => isSelected(row.original) ? 'bg-primary/5' : ''"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div v-for="i in 10" :key="i" class="p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-12 w-20 rounded-md bg-gray-200 animate-pulse" />
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 h-4 w-2/5 rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-3 w-1/4 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div class="h-3 w-[8%] rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-3 w-[8%] rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-6 w-16 rounded-full bg-gray-200 animate-pulse" />
|
||||
<div class="h-7 w-22 rounded-md bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<table v-else class="w-full min-w-[50rem]">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 bg-header">
|
||||
<th class="w-12 px-4 py-3">
|
||||
<input type="checkbox" :checked="isAllSelected" @change="toggleAll"
|
||||
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.video') }}</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.status') }}</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.size') }}</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.created') }}</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="data in videos" :key="data.id"
|
||||
class="border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'bg-primary/5': isSelected(data) }">
|
||||
<td class="px-4 py-3">
|
||||
<input type="checkbox" :checked="isSelected(data)" @change="toggleRow(data)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.title"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<VideoIcon class="text-gray-400 text-xl w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ data.description || t('video.table.noDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="severityClasses[getStatusSeverity(data.status) || 'secondary']">
|
||||
{{ data.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.createdAt, true) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
:title="t('video.table.copyLink')" @click="emit('copy', data.id!)">
|
||||
<LinkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-primary transition-colors"
|
||||
:title="t('video.table.edit')" @click="emit('edit', data.id!)">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="p-1.5 rounded-md hover:bg-red-50 text-gray-500 hover:text-red-500 transition-colors"
|
||||
:title="t('video.table.delete')" @click="emit('delete', data.id!)">
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authenticate } from "@/server/middlewares/authenticate";
|
||||
import { getGrpcMetadataFromContext } from "@/server/services/grpcClient";
|
||||
import { clientJSON } from "@/shared/secure-json-transformer";
|
||||
import { Metadata } from "@grpc/grpc-js";
|
||||
import { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc";
|
||||
import { Hono } from "hono";
|
||||
@@ -30,13 +31,13 @@ export const pathsForGET: (keyof typeof protectedRoutes)[] = ["health"];
|
||||
export function registerRpcRoutes(app: Hono) {
|
||||
const protectedHandler = exposeTinyRpc({
|
||||
routes: protectedRoutes,
|
||||
adapter: httpServerAdapter({ endpoint: "/rpc" }),
|
||||
adapter: httpServerAdapter({ endpoint: "/rpc",JSON:clientJSON }),
|
||||
});
|
||||
app.use(publicEndpoint, async (c, next) => {
|
||||
|
||||
const publicHandler = exposeTinyRpc({
|
||||
routes: publicRoutes,
|
||||
adapter: httpServerAdapter({ endpoint: "/rpc-public" }),
|
||||
adapter: httpServerAdapter({ endpoint: "/rpc-public", JSON:clientJSON }),
|
||||
});
|
||||
const res = await publicHandler({ request: c.req.raw });
|
||||
if (res) {
|
||||
|
||||
47
src/server/routes/rpc/key-resolver.ts
Normal file
47
src/server/routes/rpc/key-resolver.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// backend/key-resolver.ts
|
||||
|
||||
export type ServerKeyPair = {
|
||||
kid: string;
|
||||
publicKeyPem: string;
|
||||
privateKeyPem: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export class StaticKeyResolver {
|
||||
private readonly keys = new Map<string, ServerKeyPair>();
|
||||
private activeKid: string;
|
||||
|
||||
constructor(pairs: ServerKeyPair[]) {
|
||||
if (!pairs.length) throw new Error("At least one key pair is required");
|
||||
|
||||
for (const pair of pairs) {
|
||||
this.keys.set(pair.kid, pair);
|
||||
}
|
||||
|
||||
const active = pairs.find((x) => x.active) ?? pairs[0];
|
||||
this.activeKid = active.kid;
|
||||
}
|
||||
|
||||
getPrivateKeyByKid(kid: string): string | null {
|
||||
return this.keys.get(kid)?.privateKeyPem ?? null;
|
||||
}
|
||||
|
||||
getPublicConfig() {
|
||||
const pair = this.keys.get(this.activeKid);
|
||||
if (!pair) throw new Error("Active key not found");
|
||||
|
||||
return {
|
||||
kid: pair.kid,
|
||||
publicKeyPem: pair.publicKeyPem,
|
||||
};
|
||||
}
|
||||
|
||||
getActiveKid(): string {
|
||||
return this.activeKid;
|
||||
}
|
||||
|
||||
setActiveKid(kid: string): void {
|
||||
if (!this.keys.has(kid)) throw new Error(`Unknown kid: ${kid}`);
|
||||
this.activeKid = kid;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ export function registerWellKnownRoutes(app: Hono) {
|
||||
app.get("/health/detailed", (c) => {
|
||||
return c.json({ status: "detailed", uptime: process.uptime() });
|
||||
});
|
||||
// app.get("/trpc-secure-config", (c) => {
|
||||
// return c.json({ kid: process.env.TRPC_SECURE_KID, publicKeyBase64: process.env.TRPC_SECURE_PUBLIC_KEY });
|
||||
// });
|
||||
app.get("/trpc-secure-config", (c) => {
|
||||
return c.json({ kid: 'xUJh4/ADCkL/mZTsxSofIVTgLrTLw2C8h/X8/StUc0E=', publicKeyBase64: 'hvtS8b4RWXkau3B2UXbWhCV1NxS/97DGLfcftf/0TG8=' });
|
||||
});
|
||||
// app.get("/metrics", (c) => {
|
||||
// //TODO: Implement metrics endpoint/ Prometheus integration
|
||||
// return c.json({ message: "Metrics endpoint" });
|
||||
|
||||
164
src/shared/secure-json-transformer.ts
Normal file
164
src/shared/secure-json-transformer.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/shared/trpc-secure-transformer.ts
|
||||
import superjson from "superjson";
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
const secureConfig = { kid: 'xUJh4/ADCkL/mZTsxSofIVTgLrTLw2C8h/X8/StUc0E=', publicKeyBase64: 'hvtS8b4RWXkau3B2UXbWhCV1NxS/97DGLfcftf/0TG8=' };
|
||||
export type SecureEnvelopeV1 = {
|
||||
kid: string;
|
||||
nonce: string; // base64
|
||||
pk: string; // client public key, base64
|
||||
data: string; // ciphertext, base64
|
||||
};
|
||||
|
||||
export type ServerPublicKeyConfig = {
|
||||
kid: string;
|
||||
publicKeyBase64: string;
|
||||
};
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
let s = "";
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function fromBase64(base64: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(base64, "base64"));
|
||||
}
|
||||
const s = atob(base64);
|
||||
const out = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function utf8Encode(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function utf8Decode(value: Uint8Array): string {
|
||||
return new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
function isSecureEnvelope(value: unknown): value is SecureEnvelopeV1 {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.kid === "string" &&
|
||||
typeof v.nonce === "string" &&
|
||||
typeof v.pk === "string" &&
|
||||
typeof v.data === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function createClientKeypair() {
|
||||
const keyPair = nacl.box.keyPair();
|
||||
return {
|
||||
publicKey: keyPair.publicKey,
|
||||
secretKey: keyPair.secretKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEncryptedInputTransformer(opts: {
|
||||
serverKid: string;
|
||||
serverPublicKeyBase64: string;
|
||||
clientKeypair: { publicKey: Uint8Array; secretKey: Uint8Array };
|
||||
}): JsonTransformer {
|
||||
const serverPublicKey = fromBase64(opts.serverPublicKeyBase64);
|
||||
|
||||
return {
|
||||
stringify(object: any) {
|
||||
const payload = superjson.serialize(object);
|
||||
const plaintext = utf8Encode(JSON.stringify(payload));
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||
|
||||
const cipher = nacl.box(
|
||||
plaintext,
|
||||
nonce,
|
||||
serverPublicKey,
|
||||
opts.clientKeypair.secretKey,
|
||||
);
|
||||
|
||||
return JSON.stringify({
|
||||
kid: opts.serverKid,
|
||||
nonce: toBase64(nonce),
|
||||
pk: toBase64(opts.clientKeypair.publicKey),
|
||||
data: toBase64(cipher),
|
||||
});
|
||||
},
|
||||
|
||||
parse(object: unknown): unknown {
|
||||
// Trên client, input transformer hầu như không cần dùng deserialize.
|
||||
return object;
|
||||
},
|
||||
};
|
||||
}
|
||||
export function stringify(object: any) {
|
||||
const clientKeypair = createClientKeypair();
|
||||
const payload = superjson.serialize(object);
|
||||
const plaintext = utf8Encode(JSON.stringify(payload));
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||
|
||||
const cipher = nacl.box(
|
||||
plaintext,
|
||||
nonce,
|
||||
fromBase64(secureConfig.publicKeyBase64), // for testing, should be replaced with real server public key retrieval
|
||||
// serverPublicKey,
|
||||
clientKeypair.secretKey,
|
||||
);
|
||||
|
||||
return JSON.stringify({
|
||||
kid: secureConfig.kid,
|
||||
nonce: toBase64(nonce),
|
||||
pk: toBase64(clientKeypair.publicKey),
|
||||
data: toBase64(cipher),
|
||||
});
|
||||
}
|
||||
export function parse(d: unknown): unknown {
|
||||
const object = typeof d === "string" ? JSON.parse(d) : d;
|
||||
if (!isSecureEnvelope(object)) {
|
||||
// console.log("parse RPC payload:", object);
|
||||
return object;
|
||||
}
|
||||
|
||||
// const serverSecretKey = opts.getSecretKeyByKid(object.kid);
|
||||
// if (!serverSecretKey) {
|
||||
// throw new Error(`Unknown secure transformer kid: ${object.kid}`);
|
||||
// }
|
||||
const nonce = fromBase64(object.nonce);
|
||||
const clientPublicKey = fromBase64(object.pk);
|
||||
const ciphertext = fromBase64(object.data);
|
||||
|
||||
const opened = nacl.box.open(
|
||||
ciphertext,
|
||||
nonce,
|
||||
clientPublicKey,
|
||||
// serverSecretKey
|
||||
fromBase64(secureConfig.kid), // for testing, should be replaced with real secret key retrieval
|
||||
);
|
||||
if (!opened) {
|
||||
throw new Error("Failed to decrypt tRPC input payload");
|
||||
}
|
||||
const parsed = JSON.parse(utf8Decode(opened));
|
||||
return superjson.deserialize(parsed);
|
||||
}
|
||||
export const clientJSON: JsonTransformer = {
|
||||
stringify,
|
||||
parse,
|
||||
}
|
||||
// export function createServerInputDecryptor(opts?: {
|
||||
// getSecretKeyByKid: (kid: string) => Uint8Array | null;
|
||||
// }): Partial<JsonTransformer> {
|
||||
// return {
|
||||
// // stringify(object: unknown): any {
|
||||
// // console.log("stringify Payload:", object);
|
||||
|
||||
// // // Trên server, input transformer hầu như không cần dùng serialize.
|
||||
// // return object;
|
||||
// // },
|
||||
|
||||
// parse,
|
||||
// };
|
||||
// }
|
||||
6
src/type.d.ts
vendored
6
src/type.d.ts
vendored
@@ -12,6 +12,12 @@ declare module "@httpClientAdapter" {
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
JSON?: Partial<JsonTransformer>;
|
||||
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }>
|
||||
}): TinyRpcClientAdapter;
|
||||
}
|
||||
|
||||
interface JsonTransformer {
|
||||
parse: (v: string) => any; // TODO: eliminate proto pollution at least on server by default cf. https://github.com/fastify/secure-json-parse
|
||||
stringify: (v: any) => string;
|
||||
}
|
||||
Reference in New Issue
Block a user