Files
stream.ui/src/routes/settings/AdsVast/components/AdsVastTable.tsx
claude cc3f62a6a1 refactor: reorganize proto clients and settings UI
Move generated proto imports under the new server api path and align gRPC auth/client usage with the renamed clients. Polish settings UI details by adding a shared language icon and refining Ads VAST table presentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 14:06:51 +00:00

249 lines
9.3 KiB
TypeScript

import { useAppToast } from '@/composables/useAppToast';
import type { ColumnDef } from '@tanstack/vue-table';
import { useTranslation } from 'i18next-vue';
import { computed, defineComponent, type PropType } from 'vue';
import type { AdTemplate } from '../types';
// Components
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppSwitch from '@/components/ui/AppSwitch.vue';
import BaseTable from '@/components/ui/BaseTable.vue';
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
import { formatDate } from '../../DomainsDns/helpers';
export default defineComponent({
name: 'AdTemplateTable',
props: {
templates: { type: Array as PropType<AdTemplate[]>, required: true },
isInitialLoading: { type: Boolean, default: false },
isReadOnly: { type: Boolean, default: false },
isMutating: { type: Boolean, default: false },
saving: { type: Boolean, default: false },
deletingId: { type: String as PropType<string | null>, default: null },
togglingId: { type: String as PropType<string | null>, default: null },
defaultingId: { type: String as PropType<string | null>, default: null },
},
emits: {
edit: (template: AdTemplate) => true,
delete: (template: AdTemplate) => true,
'toggle-active': (payload: { template: AdTemplate; value: boolean }) => true,
'set-default': (template: AdTemplate) => true,
},
setup(props, { emit }) {
const toast = useAppToast();
const { t } = useTranslation();
const adFormatLabels = computed<Record<string, string>>(() => ({
'pre-roll': t('settings.adsVast.formats.preRoll'),
'mid-roll': t('settings.adsVast.formats.midRoll'),
'post-roll': t('settings.adsVast.formats.postRoll'),
}));
const getAdFormatLabel = (format?: string) => adFormatLabels.value[format || ''] || format || '-';
const getAdFormatColor = (format?: string) => {
const colors: Record<string, string> = {
'pre-roll': 'bg-blue-500/10 text-blue-500',
'mid-roll': 'bg-yellow-500/10 text-yellow-500',
'post-roll': 'bg-purple-500/10 text-purple-500',
};
return colors[format || ''] || 'bg-gray-500/10 text-gray-500';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.copiedSummary'),
detail: t('settings.adsVast.toast.copiedDetail'),
life: 2000,
});
};
const columns = computed<ColumnDef<AdTemplate>[]>(() => [
{
id: 'template',
header: t('settings.adsVast.table.template'),
accessorFn: (row) => row.name || '',
cell: ({ row }) => (
<>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-foreground">{row.original.name || ''}</span>
{row.original.isDefault && (
<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')}
</span>
)}
</div>
<p class="mt-0.5 text-xs text-foreground/50">
{t('settings.adsVast.createdOn', { date: formatDate(row.original.createdAt) || '-' })}
</p>
</>
),
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 }) => (
<div>
<span class={['rounded-full px-2 py-1 text-xs font-medium', getAdFormatColor(row.original.adFormat)]}>
{getAdFormatLabel(row.original.adFormat)}
</span>
{row.original.adFormat === 'mid-roll' && row.original.duration && (
<span class="ml-2 text-xs text-foreground/50">({row.original.duration}s)</span>
)}
</div>
),
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.vastTagUrl || '',
cell: ({ row }) => (
<div class="flex max-w-[240px] items-center gap-2">
<code class="truncate text-xs text-foreground/60">{row.original.vastTagUrl || ''}</code>
<AppButton
variant="ghost"
size="sm"
disabled={props.isMutating || !row.original.vastTagUrl}
onClick={() => copyToClipboard(row.original.vastTagUrl || '')}
v-slots={{
icon: () => <CheckIcon class="h-4 w-4" />
}}
/>
</div>
),
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(Boolean(row.isActive)),
cell: ({ row }) => (
<div class="text-center">
<AppSwitch
modelValue={Boolean(row.original.isActive)}
disabled={
props.isReadOnly ||
props.saving ||
props.deletingId !== null ||
props.defaultingId !== null ||
props.togglingId === row.original.id
}
onUpdate:modelValue={(value: boolean) => emit('toggle-active', { template: row.original, value })}
/>
</div>
),
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 }) => (
<div class="flex flex-wrap items-center justify-center gap-2">
{!row.original.isDefault && (
<AppButton
variant="ghost"
size="sm"
loading={props.defaultingId === row.original.id}
disabled={
props.isReadOnly ||
props.saving ||
props.deletingId !== null ||
props.togglingId !== null ||
props.defaultingId !== null ||
!Boolean(row.original.isActive)
}
onClick={() => emit('set-default', row.original)}
>
{t('settings.adsVast.actions.setDefault')}
</AppButton>
)}
<AppButton
variant="ghost"
size="sm"
disabled={props.isReadOnly || props.isMutating}
onClick={() => emit('edit', row.original)}
v-slots={{
icon: () => <PencilIcon class="h-4 w-4" />
}}
/>
<AppButton
variant="ghost"
size="sm"
disabled={props.isReadOnly || props.isMutating}
onClick={() => emit('delete', row.original)}
v-slots={{
icon: () => <TrashIcon class="h-4 w-4 text-danger" />
}}
/>
</div>
),
meta: {
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50 [&>div]:justify-center',
cellClass: 'px-6 py-3 text-center',
},
},
]);
return () => (
<>
{props.isInitialLoading ? (
<SettingsTableSkeleton columns={5} rows={4} />
) : (
<BaseTable
data={props.templates}
columns={columns.value}
getRowId={(row: AdTemplate, index: number) =>
row.id || `${row.name || 'template'}:${row.vastTagUrl || index}`
}
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"
v-slots={{
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>
)
}}
/>
)}
</>
);
},
});