diff --git a/components.d.ts b/components.d.ts index 104cc60..3d83647 100644 --- a/components.d.ts +++ b/components.d.ts @@ -30,7 +30,6 @@ declare module 'vue' { AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default'] BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default'] - BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] Chart: typeof import('./src/components/icons/Chart.vue')['default'] CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default'] CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] @@ -72,6 +71,7 @@ declare module 'vue' { PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default'] PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default'] PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default'] + PopupAdsRuntime: typeof import('./src/components/PopupAdsRuntime.vue')['default'] RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default'] RootLayout: typeof import('./src/components/RootLayout.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -95,6 +95,7 @@ declare module 'vue' { VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default'] VueHead: typeof import('./src/components/VueHead.tsx')['default'] WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default'] + Windows: typeof import('./src/components/icons/windows.vue')['default'] XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default'] XIcon: typeof import('./src/components/icons/XIcon.vue')['default'] } @@ -120,7 +121,6 @@ declare global { const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default'] const BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default'] - const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default'] const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default'] const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] @@ -162,6 +162,7 @@ declare global { const PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default'] const PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default'] const PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default'] + const PopupAdsRuntime: typeof import('./src/components/PopupAdsRuntime.vue')['default'] const RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default'] const RootLayout: typeof import('./src/components/RootLayout.vue')['default'] const RouterLink: typeof import('vue-router')['RouterLink'] @@ -185,6 +186,7 @@ declare global { const VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default'] const VueHead: typeof import('./src/components/VueHead.tsx')['default'] const WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default'] + const Windows: typeof import('./src/components/icons/windows.vue')['default'] const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default'] const XIcon: typeof import('./src/components/icons/XIcon.vue')['default'] } \ No newline at end of file diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 3d870a3..84aeff8 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -121,7 +121,8 @@ "playerConfigs": "Player Configs", "domains": "Allowed Domains", "ads": "Ads & VAST", - "danger": "Danger Zone" + "danger": "Danger Zone", + "popupAds": "Popup Ads" }, "content": { "fallbackTitle": "Settings", @@ -157,6 +158,10 @@ "danger": { "title": "Danger Zone", "subtitle": "Irreversible and destructive actions. Be careful!" + }, + "popupAds": { + "title": "Popup Ads", + "subtitle": "Manage your popup ad settings and preferences." } }, "notificationSettings": { @@ -501,6 +506,68 @@ "failedDetail": "Failed to load or update VAST templates." } }, + "popupAds": { + "createItem": "Add Popup Ad", + "maxTriggersLabel": "Highest URL trigger limit per session ({{count}})", + "emptyTitle": "No popup ads yet", + "emptySubtitle": "Create a popup ad to start opening URLs or injecting scripts.", + "types": { + "url": "URL", + "script": "Script" + }, + "table": { + "label": "Label", + "type": "Type", + "target": "Target", + "maxTriggersPerSession": "Max triggers/session" + }, + "dialog": { + "createTitle": "Create Popup Ad", + "editTitle": "Edit Popup Ad", + "type": "Type", + "label": "Label", + "labelPlaceholder": "e.g., Ad Network 1", + "url": "Destination URL", + "urlPlaceholder": "https://example.com/landing-page", + "script": "Script snippet", + "scriptPlaceholder": "", + "maxTriggersPerSession": "Max popup triggers per session", + "activeTitle": "Item status", + "activeDescription": "Disable an item to keep it in the table without serving it.", + "update": "Update", + "create": "Create" + }, + "info": { + "urlTitle": "URL:", + "urlDescription": "Opens in a new tab when viewer clicks.", + "scriptTitle": "Script:", + "scriptDescription": "Injects the script tag into the page (popunder networks, etc)." + }, + "confirm": { + "deleteMessage": "Are you sure you want to delete \"{{name}}\"?", + "deleteHeader": "Delete Popup Ad", + "deleteAccept": "Delete", + "deleteReject": "Cancel" + }, + "toast": { + "labelRequiredSummary": "Label required", + "labelRequiredDetail": "Please enter a label for this popup ad.", + "valueRequiredSummary": "Value required", + "valueRequiredDetail": "Please enter a URL or script snippet.", + "maxTriggersRequiredSummary": "Trigger limit required", + "maxTriggersRequiredDetail": "Please enter a max trigger count greater than 0 for URL popup ads.", + "invalidUrlSummary": "Invalid URL", + "invalidUrlDetail": "Please enter a valid URL.", + "createdSummary": "Popup ad created", + "createdDetail": "The popup ad has been added.", + "updatedSummary": "Popup ad updated", + "updatedDetail": "The popup ad has been updated.", + "deletedSummary": "Popup ad deleted", + "deletedDetail": "The popup ad has been removed.", + "failedSummary": "Action failed", + "failedDetail": "Failed to load or update popup ads." + } + }, "profile": { "title": "Profile Information", "subtitle": "Manage your personal information and account details.", @@ -768,7 +835,7 @@ }, "overview": { "welcome": { - "title": "Hello, {{{name}}}", + "title": "Hello, {{name}}", "subtitle": "Here's what's happening with your content today." }, "stats": { diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index 36f58d9..d3e14f6 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -121,7 +121,8 @@ "playerConfigs": "Cấu hình trình phát", "domains": "Tên miền được phép", "ads": "Quảng cáo & VAST", - "danger": "Vùng nguy hiểm" + "danger": "Vùng nguy hiểm", + "popupAds": "Popup Ads" }, "content": { "fallbackTitle": "Cài đặt", @@ -157,6 +158,10 @@ "danger": { "title": "Vùng nguy hiểm", "subtitle": "Hành động không thể hoàn tác và có tính phá hủy. Hãy cẩn thận!" + }, + "popupAds": { + "title": "Popup Ads", + "subtitle": "Quản lý cài đặt và tùy chọn quảng cáo popup của bạn." } }, "notificationSettings": { @@ -501,6 +506,68 @@ "failedDetail": "Không thể tải hoặc cập nhật mẫu VAST." } }, + "popupAds": { + "createItem": "Thêm popup ad", + "maxTriggersLabel": "Giới hạn trigger URL cao nhất mỗi phiên ({{count}})", + "emptyTitle": "Chưa có popup ad", + "emptySubtitle": "Tạo popup ad để bắt đầu mở URL hoặc inject script.", + "types": { + "url": "URL", + "script": "Script" + }, + "table": { + "label": "Nhãn", + "type": "Loại", + "target": "Đích", + "maxTriggersPerSession": "Số trigger tối đa/phiên" + }, + "dialog": { + "createTitle": "Tạo popup ad", + "editTitle": "Sửa popup ad", + "type": "Loại", + "label": "Nhãn", + "labelPlaceholder": "ví dụ: Ad Network 1", + "url": "URL đích", + "urlPlaceholder": "https://example.com/landing-page", + "script": "Đoạn script", + "scriptPlaceholder": "", + "maxTriggersPerSession": "Số lần popup tối đa mỗi phiên", + "activeTitle": "Trạng thái mục", + "activeDescription": "Tắt một mục để giữ nó trong bảng mà không phân phối nó.", + "update": "Cập nhật", + "create": "Tạo" + }, + "info": { + "urlTitle": "URL:", + "urlDescription": "Mở tab mới khi người xem nhấp.", + "scriptTitle": "Script:", + "scriptDescription": "Inject script tag vào trang cho các mạng popup/popunder." + }, + "confirm": { + "deleteMessage": "Bạn có chắc muốn xóa \"{{name}}\"?", + "deleteHeader": "Xóa popup ad", + "deleteAccept": "Xóa", + "deleteReject": "Hủy" + }, + "toast": { + "labelRequiredSummary": "Thiếu nhãn", + "labelRequiredDetail": "Vui lòng nhập nhãn cho popup ad này.", + "valueRequiredSummary": "Thiếu giá trị", + "valueRequiredDetail": "Vui lòng nhập URL hoặc đoạn script.", + "maxTriggersRequiredSummary": "Thiếu giới hạn trigger", + "maxTriggersRequiredDetail": "Vui lòng nhập số trigger lớn hơn 0 cho popup ad loại URL.", + "invalidUrlSummary": "URL không hợp lệ", + "invalidUrlDetail": "Vui lòng nhập URL hợp lệ.", + "createdSummary": "Đã tạo popup ad", + "createdDetail": "Popup ad đã được thêm.", + "updatedSummary": "Đã cập nhật popup ad", + "updatedDetail": "Popup ad đã được cập nhật.", + "deletedSummary": "Đã xóa popup ad", + "deletedDetail": "Popup ad đã được gỡ bỏ.", + "failedSummary": "Thao tác thất bại", + "failedDetail": "Không thể tải hoặc cập nhật popup ads." + } + }, "profile": { "title": "Thông tin hồ sơ", "subtitle": "Quản lý thông tin cá nhân và chi tiết tài khoản của bạn.", @@ -767,7 +834,7 @@ }, "overview": { "welcome": { - "title": "Xin chào, {{{name}}}", + "title": "Xin chào, {{name}}", "subtitle": "Đây là tình hình nội dung của bạn hôm nay." }, "stats": { diff --git a/src/components/DashboardLayout.vue b/src/components/DashboardLayout.vue index 3da885f..5ce8c9d 100644 --- a/src/components/DashboardLayout.vue +++ b/src/components/DashboardLayout.vue @@ -2,6 +2,7 @@ import Upload from "@/routes/upload/Upload.vue"; import DashboardNav from "./DashboardNav.vue"; import GlobalUploadIndicator from "./GlobalUploadIndicator.vue"; +import PopupAdsRuntime from "./PopupAdsRuntime.vue"; @@ -22,5 +23,6 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue"; + diff --git a/src/components/PopupAdsRuntime.vue b/src/components/PopupAdsRuntime.vue new file mode 100644 index 0000000..1dc967f --- /dev/null +++ b/src/components/PopupAdsRuntime.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/components/icons/windows.vue b/src/components/icons/windows.vue new file mode 100644 index 0000000..f5a4e38 --- /dev/null +++ b/src/components/icons/windows.vue @@ -0,0 +1,7 @@ + + diff --git a/src/components/ui/AppInput.vue b/src/components/ui/AppInput.vue index 97e19d7..509595a 100644 --- a/src/components/ui/AppInput.vue +++ b/src/components/ui/AppInput.vue @@ -1,12 +1,11 @@ diff --git a/src/composables/useNotifications.ts b/src/composables/useNotifications.ts index 5beee40..8da3fd6 100644 --- a/src/composables/useNotifications.ts +++ b/src/composables/useNotifications.ts @@ -27,6 +27,11 @@ type NotificationApiItem = { createdAt?: string; }; +type IncomingNotificationEnvelope = { + type?: string; + payload?: NotificationApiItem; +}; + const notifications = ref([]); const loading = ref(false); const loaded = ref(false); @@ -45,6 +50,31 @@ const normalizeType = (value?: string): NotificationType => { } }; +const mapNotification = (item: NotificationApiItem): AppNotification => ({ + id: item.id || '', + type: normalizeType(item.type), + title: item.title || '', + message: item.message || '', + time: '', + read: Boolean(item.read), + actionUrl: item.actionUrl || undefined, + actionLabel: item.actionLabel || undefined, + createdAt: item.createdAt, +}); + +const upsertNotification = (item: NotificationApiItem) => { + const mapped = mapNotification({ ...item, read: item.read ?? false }); + if (!mapped.id) return; + + const index = notifications.value.findIndex(notification => notification.id === mapped.id); + if (index >= 0) { + notifications.value[index] = { ...notifications.value[index], ...mapped }; + return; + } + + notifications.value = [mapped, ...notifications.value]; +}; + export function useNotifications() { const { t, i18next } = useTranslation(); @@ -62,23 +92,16 @@ export function useNotifications() { return t('notification.time.daysAgo', { count: Math.max(1, days) }); }; - const mapNotification = (item: NotificationApiItem): AppNotification => ({ - id: item.id || '', - type: normalizeType(item.type), - title: item.title || '', - message: item.message || '', + const hydrateNotification = (item: NotificationApiItem): AppNotification => ({ + ...mapNotification(item), time: formatRelativeTime(item.createdAt), - read: Boolean(item.read), - actionUrl: item.actionUrl || undefined, - actionLabel: item.actionLabel || undefined, - createdAt: item.createdAt, }); const fetchNotifications = async () => { loading.value = true; try { const response = await rpcClient.listNotifications(); - notifications.value = (response.notifications || []).map(mapNotification); + notifications.value = (response.notifications || []).map(hydrateNotification); loaded.value = true; return notifications.value; } finally { @@ -86,6 +109,22 @@ export function useNotifications() { } }; + const ingestRealtimeNotification = (raw: string | IncomingNotificationEnvelope) => { + try { + + const envelope = typeof raw === 'string' ? JSON.parse(raw) as IncomingNotificationEnvelope : raw; + if (envelope?.type !== 'notification.created' || !envelope.payload) return false; + upsertNotification(envelope.payload); + notifications.value = notifications.value.map(item => ({ + ...item, + time: formatRelativeTime(item.createdAt), + })); + return true; + } catch { + return false; + } + }; + const markRead = async (id: string) => { if (!id) return; await rpcClient.markNotificationRead({ id }); @@ -118,6 +157,7 @@ export function useNotifications() { unreadCount, locale: computed(() => i18next.resolvedLanguage), fetchNotifications, + ingestRealtimeNotification, markRead, deleteNotification, markAllRead, diff --git a/src/lib/liteMqtt.ts b/src/lib/liteMqtt.ts index f793c56..68817ab 100644 --- a/src/lib/liteMqtt.ts +++ b/src/lib/liteMqtt.ts @@ -85,7 +85,9 @@ export class TinyMqttClient implements ITinyMqttClient { break; case 0xD0: // PINGRESP break; - case 0x30: // PUBLISH + case 0x30: // PUBLISH QoS 0 + case 0x32: // PUBLISH QoS 1 + case 0x34: // PUBLISH QoS 2 this.parsePublish(data); break; } @@ -102,9 +104,32 @@ export class TinyMqttClient implements ITinyMqttClient { } private parsePublish(data: Uint8Array): void { - const tLen = (data[2] << 8) | data[3]; - const topic = this.decoder.decode(data.slice(4, 4 + tLen)); - const payload = this.decoder.decode(data.slice(4 + tLen)); + let multiplier = 1; + let remainingLength = 0; + let offset = 1; + let encodedByte = 0; + + do { + encodedByte = data[offset++]; + remainingLength += (encodedByte & 127) * multiplier; + multiplier *= 128; + } while ((encodedByte & 128) !== 0 && offset < data.length); + + const variableHeaderStart = offset; + const topicLength = (data[offset] << 8) | data[offset + 1]; + offset += 2; + + const topic = this.decoder.decode(data.slice(offset, offset + topicLength)); + offset += topicLength; + + const qos = (data[0] >> 1) & 0x03; + if (qos > 0) { + offset += 2; // packet identifier + } + + const consumedFromVariableHeader = offset - variableHeaderStart; + const payloadLength = Math.max(0, remainingLength - consumedFromVariableHeader); + const payload = this.decoder.decode(data.slice(offset, offset + payloadLength)); this.onMessage(topic, payload); } } diff --git a/src/routes/index.ts b/src/routes/index.ts index 227f5c9..2c664ae 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -204,6 +204,16 @@ const routes: RouteData[] = [ }, }, }, + { + path: "popup-ads", + name: "settings-popup-ads", + component: () => import("./settings/PopupAds/PopupAds.vue"), + meta: { + head: { + title: "Popup Ads - Holistream", + }, + }, + }, { path: "player-configs", name: "settings-player-configs", @@ -234,6 +244,7 @@ const routes: RouteData[] = [ { path: "payments", name: "admin-payments", component: () => import("./settings/admin/Payments.vue") }, { path: "plans", name: "admin-plans", component: () => import("./settings/admin/Plans.vue") }, { path: "ad-templates", name: "admin-ad-templates", component: () => import("./settings/admin/AdTemplates.vue") }, + { path: "popup-ads", name: "admin-popup-ads", component: () => import("./settings/admin/PopupAds.vue") }, { path: "player-configs", name: "admin-player-configs", component: () => import("./settings/admin/PlayerConfigs.vue") }, { path: "jobs", name: "admin-jobs", component: () => import("./settings/admin/Jobs.vue") }, { path: "agents", name: "admin-agents", component: () => import("./settings/admin/Agents.vue") }, diff --git a/src/routes/settings/AdsVast/components/AdsVastTable.tsx b/src/routes/settings/AdsVast/components/AdsVastTable.tsx index 7ad39a0..0bffecd 100644 --- a/src/routes/settings/AdsVast/components/AdsVastTable.tsx +++ b/src/routes/settings/AdsVast/components/AdsVastTable.tsx @@ -13,7 +13,7 @@ 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'; +import { formatDate } from '@/lib/utils'; export default defineComponent({ name: 'AdTemplateTable', diff --git a/src/routes/settings/DomainsDns/DomainsDns.vue b/src/routes/settings/DomainsDns/DomainsDns.vue index b81139a..4b168c5 100644 --- a/src/routes/settings/DomainsDns/DomainsDns.vue +++ b/src/routes/settings/DomainsDns/DomainsDns.vue @@ -11,8 +11,8 @@ import DomainsDnsEmbedCode from './components/DomainsDnsEmbedCode.vue'; import DomainsDnsNotices from './components/DomainsDnsNotices.vue'; import DomainsDnsTable from './components/DomainsDnsTable.vue'; import DomainsDnsToolbar from './components/DomainsDnsToolbar.vue'; -import { mapDomainItem, normalizeDomainInput } from './helpers'; -import type { DomainItem } from './types'; +import { normalizeDomainInput } from './helpers'; +import type { Domain } from '@/server/api/proto/app/v1/common'; const toast = useAppToast(); const confirm = useAppConfirm(); @@ -27,7 +27,7 @@ const { data: domainsSnapshot, error, isPending, refetch } = useQuery({ key: () => ['settings', 'domains'], query: async () => { const response = await rpcClient.listDomains(); - return (response.domains || []).map(mapDomainItem); + return (response.domains || []); }, }); @@ -126,16 +126,16 @@ const handleAddDomain = async () => { } }; -const handleRemoveDomain = (domain: DomainItem) => { +const handleRemoveDomain = (domain: Domain) => { confirm.require({ message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }), header: t('settings.domainsDns.confirm.removeHeader'), acceptLabel: t('settings.domainsDns.confirm.removeAccept'), rejectLabel: t('settings.domainsDns.confirm.removeReject'), accept: async () => { - removingId.value = domain.id; + removingId.value = domain.id!; try { - await rpcClient.deleteDomain({ id: domain.id }); + await rpcClient.deleteDomain({ id: domain.id! }); await refetch(); toast.add({ severity: 'info', diff --git a/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue b/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue index b3b18cd..f507e48 100644 --- a/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue +++ b/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue @@ -3,31 +3,31 @@ import LinkIcon from '@/components/icons/LinkIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue'; import AppButton from '@/components/ui/AppButton.vue'; import BaseTable from '@/components/ui/BaseTable.vue'; +import { formatDate } from '@/lib/utils'; import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue'; +import type { Domain } from '@/server/api/proto/app/v1/common'; import type { ColumnDef } from '@tanstack/vue-table'; import { useTranslation } from 'i18next-vue'; import { computed, h } from 'vue'; -import type { DomainItem } from '../types'; const props = defineProps<{ - domains: DomainItem[]; + domains: Domain[]; isInitialLoading: boolean; adding: boolean; removingId: string | null; }>(); const emit = defineEmits<{ - (e: 'remove', domain: DomainItem): void; + (e: 'remove', domain: Domain): void; }>(); const { t } = useTranslation(); -const columns = computed[]>(() => [ +const columns = computed[]>(() => [ { id: 'domain', header: t('settings.domainsDns.table.domain'), - accessorFn: row => row.name, - cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [ + cell: ({ row, getValue }) => 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), ]), @@ -39,8 +39,8 @@ const columns = computed[]>(() => [ { 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), + accessorFn: row => formatDate(row.createdAt), + cell: ({ getValue }) => h('span', { class: 'text-sm text-foreground/60' }, getValue()), meta: { headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50', cellClass: 'px-6 py-3', @@ -58,10 +58,7 @@ const columns = computed[]>(() => [ }, { 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', - }, + 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' }, }, ]); @@ -73,7 +70,7 @@ const columns = computed[]>(() => [ v-else :data="domains" :columns="columns" - :get-row-id="(row) => row.id" + :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" diff --git a/src/routes/settings/DomainsDns/helpers.ts b/src/routes/settings/DomainsDns/helpers.ts index eed5b4a..eb7b9b5 100644 --- a/src/routes/settings/DomainsDns/helpers.ts +++ b/src/routes/settings/DomainsDns/helpers.ts @@ -1,5 +1,3 @@ -import type { DomainApiItem, DomainItem } from './types'; - export const normalizeDomainInput = (value: string) => value .trim() .toLowerCase() @@ -7,19 +5,19 @@ export const normalizeDomainInput = (value: string) => value .replace(/^www\./, '') .replace(/\/$/, ''); -export const formatDate = (value?: string) => { - if (!value) return '-'; +// export const formatDate = (value?: string) => { +// if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value.split('T')[0] || value; - } +// const date = new Date(value); +// if (Number.isNaN(date.getTime())) { +// return value.split('T')[0] || value; +// } - return date.toISOString().split('T')[0]; -}; +// return date.toISOString().split('T')[0]; +// }; -export const mapDomainItem = (item: DomainApiItem): DomainItem => ({ - id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`, - name: item.name || '', - addedAt: formatDate(item.created_at), -}); +// export const mapDomainItem = (item: DomainApiItem): DomainItem => ({ +// id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`, +// name: item.name || '', +// addedAt: formatDate(item.created_at), +// }); diff --git a/src/routes/settings/DomainsDns/types.ts b/src/routes/settings/DomainsDns/types.ts deleted file mode 100644 index f14411b..0000000 --- a/src/routes/settings/DomainsDns/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type DomainApiItem = { - id?: string; - name?: string; - created_at?: string; -}; - -export type DomainItem = { - id: string; - name: string; - addedAt: string; -}; diff --git a/src/routes/settings/PopupAds/PopupAds.vue b/src/routes/settings/PopupAds/PopupAds.vue new file mode 100644 index 0000000..11e8d5e --- /dev/null +++ b/src/routes/settings/PopupAds/PopupAds.vue @@ -0,0 +1,242 @@ + + + diff --git a/src/routes/settings/PopupAds/components/PopupAdsDialog.vue b/src/routes/settings/PopupAds/components/PopupAdsDialog.vue new file mode 100644 index 0000000..ace6f41 --- /dev/null +++ b/src/routes/settings/PopupAds/components/PopupAdsDialog.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/routes/settings/PopupAds/components/PopupAdsTable.tsx b/src/routes/settings/PopupAds/components/PopupAdsTable.tsx new file mode 100644 index 0000000..4b9a7a8 --- /dev/null +++ b/src/routes/settings/PopupAds/components/PopupAdsTable.tsx @@ -0,0 +1,150 @@ +import PencilIcon from '@/components/icons/PencilIcon.vue'; +import TrashIcon from '@/components/icons/TrashIcon.vue'; +import LinkIcon from '@/components/icons/LinkIcon.vue'; +import AppButton from '@/components/ui/AppButton.vue'; +import AppSwitch from '@/components/ui/AppSwitch.vue'; +import BaseTable from '@/components/ui/BaseTable.vue'; +import type { ColumnDef } from '@tanstack/vue-table'; +import { useTranslation } from 'i18next-vue'; +import { computed, defineComponent, type PropType } from 'vue'; +import type { PopupAdItem } from '../types'; + +export default defineComponent({ + name: 'PopupAdsTable', + props: { + items: { type: Array as PropType, required: true }, + disabled: { type: Boolean, default: false }, + isLoading: { type: Boolean, default: false }, + currentPage: { type: Number, default: 1 }, + totalPages: { type: Number, default: 1 }, + totalRecords: { type: Number, default: 0 }, + rowsPerPage: { type: Number, default: 10 }, + pageSizeOptions: { type: Array as PropType, default: () => [] }, + canPreviousPage: { type: Boolean, default: false }, + canNextPage: { type: Boolean, default: false }, + }, + emits: { + edit: (item: PopupAdItem) => true, + delete: (item: PopupAdItem) => true, + 'toggle-active': (payload: { item: PopupAdItem; value: boolean }) => true, + 'previous-page': () => true, + 'next-page': () => true, + 'page-size-change': (value: number) => true, + }, + setup(props, { emit }) { + const { t } = useTranslation(); + + const columns = computed[]>(() => [ + { + id: 'label', + header: t('settings.popupAds.table.label'), + accessorFn: (row) => row.label || '', + cell: ({ row }) => ( +
+

{row.original.label}

+

#{row.original.id}

+
+ ), + meta: { headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50', cellClass: 'px-6 py-3' }, + }, + { + id: 'type', + header: t('settings.popupAds.table.type'), + accessorFn: (row) => row.type || '', + cell: ({ row }) => ( + + {t(`settings.popupAds.types.${row.original.type}`)} + + ), + meta: { headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50', cellClass: 'px-6 py-3' }, + }, + { + id: 'target', + header: t('settings.popupAds.table.target'), + accessorFn: (row) => row.value || '', + cell: ({ row }) => ( +
+ + {row.original.value} + +
+ ), + 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: 'maxTriggersPerSession', + header: t('settings.popupAds.table.maxTriggersPerSession'), + accessorFn: (row) => row.maxTriggersPerSession || 0, + cell: ({ row }) => {row.original.type === 'url' ? row.original.maxTriggersPerSession || 0 : '—'}, + meta: { headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50', cellClass: 'px-6 py-3 text-foreground/70' }, + }, + { + id: 'status', + header: t('common.status'), + accessorFn: (row) => Number(Boolean(row.isActive)), + cell: ({ row }) => ( +
+ emit('toggle-active', { item: 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 }) => ( +
+ emit('edit', row.original)} v-slots={{ icon: () => }} /> + emit('delete', row.original)} v-slots={{ icon: () => }} /> +
+ ), + 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 () => ( + String(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" + pagination + currentPage={props.currentPage} + totalPages={props.totalPages} + totalRecords={props.totalRecords} + rowsPerPage={props.rowsPerPage} + pageSizeOptions={props.pageSizeOptions} + canPreviousPage={props.canPreviousPage} + canNextPage={props.canNextPage} + onPrevious-page={() => emit('previous-page')} + onNext-page={() => emit('next-page')} + onPage-size-change={(value: number) => emit('page-size-change', value)} + v-slots={{ + empty: () => ( +
+ +

{t('settings.popupAds.emptyTitle')}

+

{t('settings.popupAds.emptySubtitle')}

+
+ ), + }} + /> + ); + }, +}); diff --git a/src/routes/settings/PopupAds/components/PopupAdsToolbar.vue b/src/routes/settings/PopupAds/components/PopupAdsToolbar.vue new file mode 100644 index 0000000..8cdcc8a --- /dev/null +++ b/src/routes/settings/PopupAds/components/PopupAdsToolbar.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/routes/settings/PopupAds/types.ts b/src/routes/settings/PopupAds/types.ts new file mode 100644 index 0000000..1b04441 --- /dev/null +++ b/src/routes/settings/PopupAds/types.ts @@ -0,0 +1,17 @@ +export type { PopupAd } from '@/server/api/proto/app/v1/common'; +export type { + CreatePopupAdRequest, + DeletePopupAdRequest, + UpdatePopupAdRequest, +} from '@/server/api/proto/app/v1/catalog'; + +export type PopupAdType = 'url' | 'script'; +export type PopupAdItem = PopupAd; + +export interface PopupAdFormData { + type: PopupAdType; + label: string; + value: string; + isActive: boolean; + maxTriggersPerSession: number; +} diff --git a/src/routes/settings/Settings.vue b/src/routes/settings/Settings.vue index f610e13..63e4d10 100644 --- a/src/routes/settings/Settings.vue +++ b/src/routes/settings/Settings.vue @@ -70,6 +70,7 @@ import GlobeIcon from '@/components/icons/Globe.vue'; import ShieldUser from '@/components/icons/shield-user.vue'; import UserIcon from '@/components/icons/UserIcon.vue'; import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue'; +import Windows from '@/components/icons/windows.vue'; import AppConfirmHost from '@/components/ui/AppConfirmHost.vue'; import AppToastHost from '@/components/ui/AppToastHost.vue'; import { isAdmin } from '@/lib/utils'; @@ -119,6 +120,7 @@ const menuSections = computed<{ title: string; items: MenuItem[] }[]>(() => [ items: [ { to: '/settings/domains', value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon }, { to: '/settings/ads', value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon }, + { to: '/settings/popup-ads', value: 'popup-ads', label: t('settings.menu.popupAds'), icon: Windows }, ], }, ...(isAdmin(auth.user?.role) ? [{ @@ -134,6 +136,7 @@ const menuSections = computed<{ title: string; items: MenuItem[] }[]>(() => [ title: 'Admin Operations', items: [ { to: '/settings/admin/ad-templates', value: 'admin-ad-templates', label: 'Ad Templates', description: 'VAST templates and defaults' }, + { to: '/settings/admin/popup-ads', value: 'admin-popup-ads', label: 'Popup Ads', description: 'Popup campaigns, timing and cooldowns' }, { to: '/settings/admin/player-configs', value: 'admin-player-configs', label: 'Player Configs', description: 'Cross-user player presets and defaults' }, { to: '/settings/admin/jobs', value: 'admin-jobs', label: 'Jobs', description: 'Queue, retries and live logs' }, { to: '/settings/admin/agents', value: 'admin-agents', label: 'Agents', description: 'Workers, health and maintenance' }, @@ -193,6 +196,10 @@ const content = computed(() => ({ title: t('settings.content.ads.title'), subtitle: t('settings.content.ads.subtitle') }, + 'settings-popup-ads': { + title: t('settings.content.popupAds.title'), + subtitle: t('settings.content.popupAds.subtitle') + }, 'settings-player-configs': { title: t('settings.content.playerConfigs.title'), subtitle: t('settings.content.playerConfigs.subtitle') @@ -225,6 +232,10 @@ const content = computed(() => ({ title: 'Ad Templates', subtitle: 'VAST templates, ownership metadata and default assignments.', }, + 'admin-popup-ads': { + title: 'Popup Ads', + subtitle: 'Popup campaigns, timing windows and cooldown controls across users.', + }, 'admin-player-configs': { title: 'Player Configs', subtitle: 'Cross-user player presets, flags and default assignments.', diff --git a/src/routes/settings/admin/PopupAds.vue b/src/routes/settings/admin/PopupAds.vue new file mode 100644 index 0000000..545d1ef --- /dev/null +++ b/src/routes/settings/admin/PopupAds.vue @@ -0,0 +1,306 @@ + + +