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:
2026-03-17 18:54:14 +07:00
parent 90d8409aa9
commit fa88fe26b3
34 changed files with 2516 additions and 1667 deletions

View File

@@ -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>