feat: add admin components for input, metrics, tables, and user forms

- Introduced AdminInput component for standardized input fields.
- Created AdminMetricCard for displaying metrics with customizable tones.
- Added AdminPlaceholderTable for loading states in tables.
- Developed AdminSectionCard for consistent section layouts.
- Implemented AdminSectionShell for organizing admin sections.
- Added AdminSelect for dropdown selections with v-model support.
- Created AdminTable for displaying tabular data with loading and empty states.
- Introduced AdminTextarea for multi-line text input.
- Developed AdminUserFormFields for user creation and editing forms.
- Added useAdminPageHeader composable for managing admin page header state.
This commit is contained in:
2026-03-24 07:08:44 +00:00
parent e854c68ad0
commit b60f65e4d1
100 changed files with 9270 additions and 2204 deletions

View File

@@ -9,6 +9,7 @@
"@grpc/grpc-js": "^1.14.3",
"@hattip/adapter-node": "^0.0.49",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.0",
"@hono/node-server": "^1.19.11",
"@hono/zod-validator": "^0.7.6",
"@pinia/colada": "^1.0.0",
@@ -176,6 +177,8 @@
"@hiogawa/tiny-rpc": ["@hiogawa/tiny-rpc@0.2.3-pre.18", "", {}, "sha512-BiNHrutG9G9yV622QvkxZxF+PhkaH2Aspp4/X1KYTfnaQTcg4fFUTBWf5Kf533swon2SuVJwi6U6H1LQbhVOQQ=="],
"@hiogawa/utils": ["@hiogawa/utils@1.7.0", "", {}, "sha512-ghiEFWBR1NENoHn+lSuW7liicTIzVPN+8Srm5UedCTw43gus0mlse6Wp2lz6GmbOXJ/CalMPp/0Tz2X8tajkAg=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],

4
components.d.ts vendored
View File

@@ -61,6 +61,7 @@ declare module 'vue' {
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
@@ -80,7 +81,6 @@ declare module 'vue' {
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
User2: typeof import('./src/components/icons/User2.vue')['default']
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default']
@@ -146,6 +146,7 @@ declare global {
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
const OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
@@ -165,7 +166,6 @@ declare global {
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
const User2: typeof import('./src/components/icons/User2.vue')['default']
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default']

View File

@@ -11,6 +11,7 @@
"@grpc/grpc-js": "^1.14.3",
"@hattip/adapter-node": "^0.0.49",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.0",
"@hono/node-server": "^1.19.11",
"@hono/zod-validator": "^0.7.6",
"@pinia/colada": "^1.0.0",

View File

@@ -112,7 +112,8 @@
"security": "Security",
"billing": "Billing & Plans",
"notifications": "Notifications",
"player": "Player",
"playerGroup": "Player",
"playerConfigs": "Player Configs",
"domains": "Allowed Domains",
"ads": "Ads & VAST",
"danger": "Danger Zone"
@@ -128,9 +129,9 @@
"title": "Notifications",
"subtitle": "Choose how you want to receive notifications and updates."
},
"player": {
"title": "Player Settings",
"subtitle": "Configure default video player behavior and features."
"preferences": {
"title": "Preferences",
"subtitle": "Manage your account preferences and notification channels."
},
"billing": {
"title": "Billing & Plans",
@@ -144,6 +145,10 @@
"title": "Ads & VAST",
"subtitle": "Create and manage VAST ad templates for your videos."
},
"playerConfigs": {
"title": "Player Configs",
"subtitle": "Create and manage player configurations for your videos."
},
"danger": {
"title": "Danger Zone",
"subtitle": "Irreversible and destructive actions. Be careful!"
@@ -293,6 +298,126 @@
"failedDetail": "Failed to load or update domains."
}
},
"playerConfigs": {
"createConfig": "Create Config",
"infoBanner": "Player configs let you customize playback behavior such as autoplay, loop, controls, and casting features.",
"freePlanTitle": "Free plan limit",
"freePlanMessage": "Free accounts can create and manage 1 player config. After you create one, create is disabled until you delete it.",
"reconciliationTitle": "Too many configs for free plan",
"reconciliationMessage": "Your account still has more than 1 player config from a previous paid plan. Delete extra configs until only 1 remains to edit, enable, or set a default again.",
"readOnlyTitle": "Free plan limit",
"readOnlyMessage": "Free accounts can manage 1 player config. Delete extra configs after downgrade to continue editing.",
"defaultBadge": "Default",
"createdOn": "Created {{date}}",
"emptyTitle": "No player configs yet",
"emptySubtitle": "Create your first config to customize video playback",
"items": {
"autoplay": {
"title": "Autoplay",
"description": "Automatically start videos when loaded"
},
"loop": {
"title": "Loop",
"description": "Repeat video when it ends"
},
"muted": {
"title": "Muted",
"description": "Start videos with sound muted"
},
"showControls": {
"title": "Show Controls",
"description": "Display player controls during playback"
},
"pip": {
"title": "Picture in Picture",
"description": "Enable Picture-in-Picture mode"
},
"airplay": {
"title": "AirPlay",
"description": "Allow streaming to Apple devices via AirPlay"
},
"chromecast": {
"title": "Chromecast",
"description": "Allow casting to Chromecast devices"
},
"encrytionM3u8": {
"title": "HLS Encryption (m3u8)",
"description": "Enable encryption for HLS streams."
}
},
"badges": {
"autoplay": "Autoplay",
"loop": "Loop",
"muted": "Muted",
"controls": "Controls",
"pip": "PiP",
"airplay": "AirPlay",
"chromecast": "Chromecast",
"encrytionM3u8": "Encrypted HLS",
"logo": "Logo"
},
"state": {
"enabled": "enabled",
"disabled": "disabled"
},
"actions": {
"default": "Default",
"setDefault": "Set Default"
},
"table": {
"name": "Name",
"settings": "Settings"
},
"dialog": {
"editTitle": "Edit Config",
"createTitle": "Create Player Config",
"name": "Config Name",
"namePlaceholder": "e.g., Mobile Player, Desktop Player",
"description": "Description",
"descriptionPlaceholder": "Brief description for this config",
"playbackOptions": "Playback Options",
"castingOptions": "Casting Options",
"advancedOptions": "Advanced Options",
"logoUrl": "Logo URL",
"logoUrlPlaceholder": "https://example.com/logo.png",
"logoUrlHint": "Optional logo image shown in the player overlay.",
"defaultLabel": "Default Config",
"defaultCheckbox": "Use this config as default for new videos",
"defaultHint": "When enabled, newly created videos will automatically use this active config.",
"defaultDisabledHint": "Please enable this config before setting it as default.",
"update": "Update",
"create": "Create"
},
"confirm": {
"deleteMessage": "Are you sure you want to delete \"{name}\"?",
"deleteHeader": "Delete Config",
"deleteAccept": "Delete",
"deleteReject": "Cancel"
},
"toast": {
"nameRequiredSummary": "Name required",
"nameRequiredDetail": "Please enter a config name.",
"updatedSummary": "Config updated",
"updatedDetail": "Player config has been updated.",
"createdSummary": "Config created",
"createdDetail": "Player config has been created.",
"enabledSummary": "Config enabled",
"disabledSummary": "Config disabled",
"defaultUpdatedSummary": "Default updated",
"defaultUpdatedDetail": "{name} is now the default config for new videos.",
"upgradeRequiredSummary": "Config limit reached",
"upgradeRequiredDetail": "Free accounts can only have 1 player config.",
"limitSummary": "Config limit reached",
"limitDetail": "Free accounts can only have 1 player config.",
"reconciliationSummary": "Delete extra configs",
"reconciliationDetail": "Delete extra player configs until only 1 remains to continue managing them on the free plan.",
"toggleDetail": "{name} has been {state}.",
"deletedSummary": "Config deleted",
"deletedDetail": "Player config has been removed.",
"failedSummary": "Action failed",
"failedDetail": "Failed to load or update player configs."
}
},
"adsVast": {
"createTemplate": "Create Template",
"infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.",
@@ -629,6 +754,13 @@
"toast": {
"dismissAria": "Dismiss"
},
"network": {
"offline": {
"title": "You're offline",
"description": "Your internet connection appears to be unavailable. Check your network and we'll reconnect automatically when you're back online.",
"action": "Try again"
}
},
"overview": {
"welcome": {
"title": "Hello, {{name}}",
@@ -638,7 +770,27 @@
"totalVideos": "Total Videos",
"totalViews": "Total Views",
"storageUsed": "Storage Used",
"trendVsLastMonth": "vs last month"
"trendVsLastMonth": "vs last month",
"unlimited": "Unlimited"
},
"admin-quickActions": {
"title": "Admin Quick Actions",
"manageUsers": {
"title": "Manage Users",
"description": "View and manage all user accounts"
},
"viewReports": {
"title": "View Reports",
"description": "Access detailed analytics and reports"
},
"systemSettings": {
"title": "System Settings",
"description": "Configure system-wide settings and preferences"
},
"billingOverview": {
"title": "Billing Overview",
"description": "Monitor billing and subscription details"
}
},
"quickActions": {
"title": "Quick Actions",
@@ -1008,7 +1160,7 @@
"description": "Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer."
},
"live": {
"title": "Live Streaming API",
"title": "Streaming API",
"description": "Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.",
"status": "Live Status",
"onAir": "On Air",

View File

@@ -112,7 +112,8 @@
"security": "Bảo mật",
"billing": "Thanh toán & Gói",
"notifications": "Thông báo",
"player": "Trình phát",
"playerGroup": "Trình phát",
"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"
@@ -128,9 +129,9 @@
"title": "Thông báo",
"subtitle": "Chọn cách bạn muốn nhận thông báo và cập nhật."
},
"player": {
"title": "Cài đặt trình phát",
"subtitle": "Cấu hình hành vinh năng mặc định của trình phát video."
"preferences": {
"title": "Tùy chọn",
"subtitle": "Quản lý các tùy chọn tài khoảnnh thông báo của bạn."
},
"billing": {
"title": "Thanh toán & Gói",
@@ -144,6 +145,10 @@
"title": "Quảng cáo & VAST",
"subtitle": "Tạo và quản lý mẫu quảng cáo VAST cho video."
},
"playerConfigs": {
"title": "Cấu hình trình phát",
"subtitle": "Tạo và quản lý cấu hình trình phát cho video."
},
"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!"
@@ -293,6 +298,126 @@
"failedDetail": "Không thể tải hoặc cập nhật danh sách tên miền."
}
},
"playerConfigs": {
"createConfig": "Tạo cấu hình",
"infoBanner": "Cấu hình trình phát cho phép tùy chỉnh hành vi phát video như tự động phát, lặp, hiển thị điều khiển và các tính năng casting.",
"freePlanTitle": "Giới hạn gói free",
"freePlanMessage": "Tài khoản free có thể tạo và quản lý 1 player config. Sau khi đã có 1 config, bạn cần xóa nó trước khi tạo config mới.",
"reconciliationTitle": "Có quá nhiều config cho gói free",
"reconciliationMessage": "Tài khoản của bạn vẫn còn hơn 1 player config từ gói paid trước đó. Hãy xóa bớt cho đến khi chỉ còn 1 config để có thể sửa, bật/tắt hoặc đặt mặc định trở lại.",
"readOnlyTitle": "Giới hạn gói free",
"readOnlyMessage": "Tài khoản free có thể quản lý 1 player config. Sau khi downgrade, hãy xóa bớt config dư để tiếp tục chỉnh sửa.",
"defaultBadge": "Mặc định",
"createdOn": "Tạo ngày {{date}}",
"emptyTitle": "Chưa có cấu hình",
"emptySubtitle": "Tạo config đầu tiên để tùy chỉnh trải nghiệm phát video",
"items": {
"autoplay": {
"title": "Tự phát",
"description": "Tự động phát video khi tải xong"
},
"loop": {
"title": "Lặp lại",
"description": "Phát lại video khi kết thúc"
},
"muted": {
"title": "Tắt tiếng",
"description": "Bắt đầu video với âm thanh tắt"
},
"showControls": {
"title": "Hiển thị điều khiển",
"description": "Hiển thị thanh điều khiển phát video"
},
"pip": {
"title": "Picture in Picture",
"description": "Bật chế độ Picture-in-Picture"
},
"airplay": {
"title": "AirPlay",
"description": "Cho phép phát tới thiết bị Apple qua AirPlay"
},
"chromecast": {
"title": "Chromecast",
"description": "Cho phép cast tới thiết bị Chromecast"
},
"encrytionM3u8": {
"title": "Mã hóa HLS (m3u8)",
"description": "Bật mã hóa cho luồng HLS."
}
},
"badges": {
"autoplay": "Tự phát",
"loop": "Lặp",
"muted": "Tắt tiếng",
"controls": "Điều khiển",
"pip": "PiP",
"airplay": "AirPlay",
"chromecast": "Chromecast",
"encrytionM3u8": "HLS mã hóa",
"logo": "Logo"
},
"state": {
"enabled": "bật",
"disabled": "tắt"
},
"actions": {
"default": "Mặc định",
"setDefault": "Đặt mặc định"
},
"table": {
"name": "Tên",
"settings": "Cài đặt"
},
"dialog": {
"editTitle": "Sửa cấu hình",
"createTitle": "Tạo cấu hình trình phát",
"name": "Tên cấu hình",
"namePlaceholder": "ví dụ: Mobile Player, Desktop Player",
"description": "Mô tả",
"descriptionPlaceholder": "Mô tả ngắn cho cấu hình này",
"playbackOptions": "Tùy chọn phát lại",
"castingOptions": "Tùy chọn casting",
"advancedOptions": "Tùy chọn nâng cao",
"logoUrl": "URL logo",
"logoUrlPlaceholder": "https://example.com/logo.png",
"logoUrlHint": "Logo tùy chọn hiển thị trong lớp phủ của trình phát.",
"defaultLabel": "Cấu hình mặc định",
"defaultCheckbox": "Dùng cấu hình này mặc định cho video mới",
"defaultHint": "Khi bật, video mới tạo sẽ tự động dùng cấu hình đang active này.",
"defaultDisabledHint": "Hãy bật cấu hình này trước khi đặt làm mặc định.",
"update": "Cập nhật",
"create": "Tạo"
},
"confirm": {
"deleteMessage": "Bạn có chắc muốn xóa \"{name}\"?",
"deleteHeader": "Xóa cấu hình",
"deleteAccept": "Xóa",
"deleteReject": "Hủy"
},
"toast": {
"nameRequiredSummary": "Thiếu tên cấu hình",
"nameRequiredDetail": "Vui lòng nhập tên cấu hình.",
"updatedSummary": "Đã cập nhật cấu hình",
"updatedDetail": "Cấu hình trình phát đã được cập nhật.",
"createdSummary": "Đã tạo cấu hình",
"createdDetail": "Cấu hình trình phát đã được tạo.",
"enabledSummary": "Đã bật cấu hình",
"disabledSummary": "Đã tắt cấu hình",
"defaultUpdatedSummary": "Đã cập nhật mặc định",
"defaultUpdatedDetail": "{name} hiện là cấu hình mặc định cho video mới.",
"upgradeRequiredSummary": "Đã đạt giới hạn cấu hình",
"upgradeRequiredDetail": "Tài khoản free chỉ có thể có 1 player config.",
"limitSummary": "Đã đạt giới hạn cấu hình",
"limitDetail": "Tài khoản free chỉ có thể có 1 player config.",
"reconciliationSummary": "Hãy xóa bớt config",
"reconciliationDetail": "Hãy xóa các player config dư cho đến khi chỉ còn 1 config để tiếp tục quản lý trên gói free.",
"toggleDetail": "{name} đã được {state}.",
"deletedSummary": "Đã xóa cấu hình",
"deletedDetail": "Cấu hình trình phát đã được gỡ bỏ.",
"failedSummary": "Thao tác thất bại",
"failedDetail": "Không thể tải hoặc cập nhật cấu hình trình phát."
}
},
"adsVast": {
"createTemplate": "Tạo mẫu",
"infoBanner": "VAST (Video Ad Serving Template) là schema XML dùng để phân phối ad tags cho trình phát video.",
@@ -628,6 +753,13 @@
"toast": {
"dismissAria": "Đóng"
},
"network": {
"offline": {
"title": "Bạn đang ngoại tuyến",
"description": "Có vẻ như kết nối internet đã bị ngắt. Hãy kiểm tra mạng, ứng dụng sẽ tự kết nối lại khi bạn có mạng trở lại.",
"action": "Thử lại"
}
},
"overview": {
"welcome": {
"title": "Xin chào, {{name}}",
@@ -637,7 +769,23 @@
"totalVideos": "Tổng số video",
"totalViews": "Tổng lượt xem",
"storageUsed": "Dung lượng đã dùng",
"trendVsLastMonth": "so với tháng trước"
"trendVsLastMonth": "so với tháng trước",
"unlimited": "Không giới hạn"
},
"admin-quickActions": {
"title": "Thao tác nhanh cho quản trị viên",
"manageUsers": {
"title": "Quản lý người dùng",
"description": "Xem và quản lý tất cả người dùng"
},
"viewReports": {
"title": "Xem báo cáo",
"description": "Phân tích hiệu suất hệ thống và hoạt động của người dùng"
},
"systemSettings": {
"title": "Cài đặt hệ thống",
"description": "Cấu hình cài đặt và tùy chọn của hệ thống"
}
},
"quickActions": {
"title": "Thao tác nhanh",
@@ -1007,7 +1155,7 @@
"description": "Nội dung được phân phối từ hơn 200 PoP trên toàn thế giới. Tự động chọn vùng để có độ trễ thấp nhất cho mọi người xem."
},
"live": {
"title": "Live Streaming API",
"title": "Streaming API",
"description": "Mở rộng tới hàng triệu người xem đồng thời với độ trễ cực thấp. Hỗ trợ RTMP ingest và HLS playback sẵn có.",
"status": "Trạng thái trực tiếp",
"onAir": "Đang phát",

View File

@@ -38,20 +38,6 @@ const links = computed<Record<string, any>>(() => {
},
{ href: "/settings", label: t("nav.settings"), icon: SettingsIcon, action: null, className },
] as const;
if (isAdmin.value) {
return [
...baseLinks,
{
href: "/admin/overview",
label: "Admin Console",
icon: LayoutDashboard,
action: null,
className,
} as const,
];
}
return baseLinks;
});
</script>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { useTranslation } from 'i18next-vue'
import { onBeforeUnmount, onMounted } from 'vue'
const { t } = useTranslation()
const { isOffline, startListening, stopListening } = useNetworkStatus()
onMounted(() => {
startListening()
})
onBeforeUnmount(() => {
stopListening()
})
function reloadPage() {
if (typeof window === 'undefined') return
window.location.reload()
}
</script>
<template>
<div
v-if="isOffline"
class="fixed inset-0 z-[10000] flex items-center justify-center bg-slate-950/80 px-6 backdrop-blur-sm"
role="alert"
aria-live="assertive"
>
<div class="w-full max-w-md rounded-2xl border border-border bg-white p-8 text-center shadow-2xl">
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-danger/10 text-danger">
<svg
class="h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.86a10 10 0 0 1 14 0" />
<path d="M8.5 16.43a5 5 0 0 1 7 0" />
<path d="M12 20h.01" />
<path d="M3 3l18 18" />
</svg>
</div>
<h2 class="text-xl font-semibold text-foreground">
{{ t('network.offline.title') }}
</h2>
<p class="mt-3 text-sm leading-6 text-foreground/70">
{{ t('network.offline.description') }}
</p>
<div class="mt-6 flex justify-center">
<AppButton @click="reloadPage">
{{ t('network.offline.action') }}
</AppButton>
</div>
</div>
</div>
</template>

View File

@@ -1,10 +1,12 @@
<template>
<ClientOnly>
<AppTopLoadingBar />
<OfflineOverlay />
</ClientOnly>
<router-view/>
<router-view />
</template>
<script setup lang="ts">
import ClientOnly from '@/components/ClientOnly';
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
import OfflineOverlay from '@/components/OfflineOverlay.vue'
</script>

View File

@@ -7,7 +7,7 @@ interface Trend {
isPositive: boolean;
}
interface Props {
export interface StatProps {
title: string;
value: string | number;
icon?: string | VNode;
@@ -15,7 +15,7 @@ interface Props {
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
}
withDefaults(defineProps<Props>(), {
withDefaults(defineProps<StatProps>(), {
color: 'primary'
});
@@ -49,7 +49,7 @@ const iconColors = {
<div class="relative z-10">
<div class="flex items-start justify-between mb-3">
<div>
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
<p class="text-sm font-medium text-gray-600 mb-1">{{ $t(title) }}</p>
<p class="text-3xl font-bold text-gray-900">{{ value }}</p>
</div>

View File

@@ -1,6 +1,6 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="#a6acb9"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="currentColor"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="518" viewBox="-10 -244 500 518"><path d="M461-229c12 5 19 16 19 29v416c0 13-7 24-19 29-11 5-25 3-34-5l-47-41c-43-38-98-60-156-63v96c0 18-14 32-32 32h-32c-18 0-32-14-32-32v-96C57 136 0 79 0 8s57-128 128-128h85c61 0 121-23 167-63l47-41c9-8 23-10 34-5zM224 72c70 3 138 29 192 74v-276c-54 45-122 71-192 74V72z" fill="currentColor"/></svg>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="currentColor"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -242 500 516"><path d="M448-194v404l-26-24c-50-47-114-75-182-81V-89c68-6 132-34 182-81l26-24zM240 137c60 6 116 31 160 72l34 32c5 4 12 7 19 7 15 0 27-12 27-27v-425c0-16-12-28-27-28-7 0-14 3-19 8l-34 31c-50 47-116 73-185 73h-87C57-120 0-63 0 8c0 60 41 110 96 124v84c0 27 22 48 48 48h48c27 0 48-21 48-48v-79zm-40-1h8v80c0 9-7 16-16 16h-48c-9 0-16-7-16-16v-80h72zm0-224h8v192h-80c-53 0-96-43-96-96s43-96 96-96h72z" fill="currentColor"/></svg>
</template>
<script lang="ts" setup>
defineProps<{

View File

@@ -1,5 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="#a6acb9"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="currentColor"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="currentColor"/></svg>
</template>
<script lang="ts" setup>

View File

@@ -1,5 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#a6acb9"/><path d="M10 106h512v64H10zm0 0z" fill="currentColor"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 106h512v64H10zm0 0z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="currentColor"/></svg>
</template>
<script lang="ts" setup>

View File

@@ -2,10 +2,10 @@
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
<path
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
fill="#a6acb9" />
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
fill="currentColor" />
fill="var(--colors-primary-DEFAULT)" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
<path

View File

@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue';
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type UiButtonSize = 'sm' | 'md' | 'lg';
const props = withDefaults(
defineProps<{
variant?: UiButtonVariant;
size?: UiButtonSize;
block?: boolean;
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
}>(),
{
@@ -15,10 +18,13 @@ const props = withDefaults(
size: 'md',
block: false,
disabled: false,
loading: false,
type: 'button',
},
);
const isDisabled = computed(() => props.disabled || props.loading);
const classes = computed(() => {
const variants: Record<UiButtonVariant, string> = {
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
@@ -34,7 +40,7 @@ const classes = computed(() => {
};
return [
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color,opacity] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
variants[props.variant],
sizes[props.size],
props.block ? 'w-full' : '',
@@ -43,7 +49,13 @@ const classes = computed(() => {
</script>
<template>
<button :type="type" :disabled="disabled" :class="classes">
<button :type="type" :disabled="isDisabled" :class="classes" :aria-busy="loading || undefined">
<span
v-if="loading"
class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent"
aria-hidden="true"
/>
<slot v-else name="icon" />
<slot />
</button>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
// Định nghĩa cấu trúc dữ liệu
interface SelectOption {
label: string;
value: string | number;
@@ -14,15 +13,14 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Vui lòng chọn...',
disabled: false
placeholder: 'Please select...',
disabled: false,
});
// Sử dụng defineModel thay cho props/emits thủ công
const modelValue = defineModel<string | number>();
const options = ref<SelectOption[]>([]);
const loading = ref<boolean>(false);
const loading = ref(false);
const error = ref<string | null>(null);
const fetchData = async () => {
@@ -30,29 +28,24 @@ const fetchData = async () => {
error.value = null;
try {
options.value = await props.loadOptions();
} catch (err) {
error.value = 'Lỗi kết nối';
} catch {
error.value = 'Failed to load options';
} finally {
loading.value = false;
}
};
onMounted(fetchData);
// Tự động load lại nếu hàm fetch thay đổi
watch(() => props.loadOptions, fetchData);
</script>
<template>
<div class="flex items-center gap-3">
<div class="relative w-full max-w-64">
<div class="space-y-2">
<div class="relative w-full">
<select
v-model="modelValue"
:disabled="loading || disabled"
class="w-full appearance-none rounded-lg border border-gray-300 bg-white px-4 py-2 pr-10
text-gray-700 outline-none transition-all
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
class="w-full appearance-none rounded-md border border-border bg-header px-3 py-2 pr-10 text-sm text-foreground outline-none transition-all focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="" disabled>{{ placeholder }}</option>
<option
@@ -64,21 +57,19 @@ watch(() => props.loadOptions, fetchData);
</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
<div class="i-carbon-chevron-down text-lg" />
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-foreground/40">
<div v-if="loading" class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent" />
<div v-else class="i-carbon-chevron-down text-lg" />
</div>
</div>
<div v-if="loading" class="flex items-center text-blue-500">
<div class="i-carbon-circle-dash animate-spin text-xl" />
</div>
<button
v-if="error"
type="button"
@click="fetchData"
class="text-xs text-red-500 underline hover:text-red-600 transition"
class="text-xs font-medium text-danger transition hover:opacity-80"
>
Thử lại?
{{ error }} · Retry
</button>
</div>
</template>

View File

@@ -0,0 +1,47 @@
import { ref } from 'vue'
const isOffline = ref(false)
let listenersCount = 0
function syncNetworkStatus() {
if (typeof navigator === 'undefined') return
isOffline.value = !navigator.onLine
}
function handleNetworkStatusChange() {
syncNetworkStatus()
}
function startListening() {
if (typeof window === 'undefined') return
if (listenersCount === 0) {
syncNetworkStatus()
window.addEventListener('online', handleNetworkStatusChange)
window.addEventListener('offline', handleNetworkStatusChange)
}
listenersCount += 1
}
function stopListening() {
if (typeof window === 'undefined' || listenersCount === 0) return
listenersCount -= 1
if (listenersCount === 0) {
window.removeEventListener('online', handleNetworkStatusChange)
window.removeEventListener('offline', handleNetworkStatusChange)
}
}
export function useNetworkStatus() {
return {
isOffline,
syncNetworkStatus,
startListening,
stopListening,
}
}

View File

@@ -10,13 +10,8 @@ export type SettingsPreferencesSnapshot = {
pushNotifications: boolean;
marketingNotifications: boolean;
telegramNotifications: boolean;
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
language: string;
locale: string;
};
export type NotificationSettingsDraft = {
@@ -26,17 +21,6 @@ export type NotificationSettingsDraft = {
telegram: boolean;
};
export type PlayerSettingsDraft = {
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
encrytion_m3u8: boolean;
};
type PreferencesResponse = {
preferences?: Preferences;
};
@@ -46,13 +30,8 @@ const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
pushNotifications: true,
marketingNotifications: false,
telegramNotifications: false,
autoplay: false,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
chromecast: true,
language: 'en',
locale: 'en',
};
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
@@ -63,13 +42,8 @@ const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreference
pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay,
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop,
muted: preferences?.muted ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.muted,
showControls: preferences?.showControls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
pip: preferences?.pip ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pip,
airplay: preferences?.airplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.airplay,
chromecast: preferences?.chromecast ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.chromecast,
language: preferences?.language ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.language,
locale: preferences?.locale ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.locale,
};
};
@@ -82,19 +56,6 @@ export const createNotificationSettingsDraft = (
telegram: snapshot.telegramNotifications,
});
export const createPlayerSettingsDraft = (
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
): PlayerSettingsDraft => ({
autoplay: snapshot.autoplay,
loop: snapshot.loop,
muted: snapshot.muted,
showControls: snapshot.showControls,
pip: snapshot.pip,
airplay: snapshot.airplay,
chromecast: snapshot.chromecast,
encrytion_m3u8: snapshot.chromecast
});
export const toNotificationPreferencesPayload = (
draft: NotificationSettingsDraft,
): UpdatePreferencesRequest => ({
@@ -104,18 +65,6 @@ export const toNotificationPreferencesPayload = (
telegramNotifications: draft.telegram,
});
export const toPlayerPreferencesPayload = (
draft: PlayerSettingsDraft,
): UpdatePreferencesRequest => ({
autoplay: draft.autoplay,
loop: draft.loop,
muted: draft.muted,
showControls: draft.showControls,
pip: draft.pip,
airplay: draft.airplay,
chromecast: draft.chromecast,
});
export function useSettingsPreferencesQuery() {
return useQuery({
key: () => SETTINGS_PREFERENCES_QUERY_KEY,

View File

@@ -97,3 +97,7 @@ export const getStatusSeverity = (status: string = "") => {
return 'info';
}
};
export const isAdmin = (role: string = "") => {
const r = String(role).toLowerCase();
return r === "admin" || r === "superadmin";
};

View File

@@ -1,138 +0,0 @@
<script setup lang="ts">
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();
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 activeSection = computed(() => {
const allSections = menuSections.map((section) => section.items).flat();
return allSections.find((section) => route.path === section.to || route.path.startsWith(`${section.to}/`)) ?? allSections[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>
<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="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">
<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>
</template>

View File

@@ -1,112 +0,0 @@
<script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient";
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>>;
const loading = ref(true);
const error = ref<string | null>(null);
const dashboard = ref<AdminDashboard | null>(null);
const cards = computed(() => {
const data = dashboard.value;
return [
{ title: "Total users", value: data?.totalUsers ?? 0, note: `${data?.newUsersToday ?? 0} new today` },
{ title: "Total videos", value: data?.totalVideos ?? 0, note: `${data?.newVideosToday ?? 0} new today` },
{ title: "Payments", value: data?.totalPayments ?? 0, note: "Completed finance events" },
{ title: "Revenue", value: data?.totalRevenue ?? 0, note: "Lifetime gross amount" },
];
});
const secondaryCards = computed(() => {
const data = dashboard.value;
return [
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0 },
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0 },
{ title: "New users today", value: data?.newUsersToday ?? 0 },
{ title: "New videos today", value: data?.newVideosToday ?? 0 },
];
});
const highlights = computed(() => {
const data = dashboard.value;
return [
{ label: "Acquisition", value: `${data?.newUsersToday ?? 0} user signups in the current day window.` },
{ label: "Content velocity", value: `${data?.newVideosToday ?? 0} newly created videos landed today.` },
{ label: "Catalog depth", value: `${data?.totalAdTemplates ?? 0} ad templates available to pair with uploads.` },
];
});
const loadDashboard = async () => {
loading.value = true;
error.value = null;
try {
dashboard.value = await rpcClient.getAdminDashboard();
} catch (err: any) {
error.value = err?.message || "Failed to load admin dashboard";
} finally {
loading.value = false;
}
};
useAdminPageHeader(() => ({
eyebrow: "Control room",
badge: "Realtime-ready summary",
actions: [{
label: "Refresh metrics",
variant: "secondary",
onClick: loadDashboard,
}],
}));
onMounted(loadDashboard);
</script>
<template>
<AdminSectionShell>
<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-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-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)]">
<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>
</SettingsSectionCard>
<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>
</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>

View File

@@ -1,11 +0,0 @@
<template>
<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 class="min-w-0">
<slot />
</div>
</section>
</template>

View File

@@ -37,8 +37,28 @@
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div>
<div v-if="refUsername" class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700">
Signing up with referral: <span class="font-medium">@{{ refUsername }}</span>
</div>
<AppButton type="submit" class="w-full">{{ t('auth.signup.createAccount') }}</AppButton>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">{{ t('auth.login.google') }}</span>
</div>
</div>
<AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2" @click="signupWithGoogle">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
</svg>
Continue with Google
</AppButton>
<p class="mt-4 text-center text-sm text-gray-600">
{{ t('auth.signup.alreadyHave') }}
<router-link to="/login"
@@ -50,13 +70,16 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { reactive, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import { useRoute } from 'vue-router';
import { z } from 'zod';
const auth = useAuthStore();
const route = useRoute();
const showPassword = ref(false);
const { t } = useTranslation();
const refUsername = computed(() => String(route.query.ref || '').trim());
const form = reactive({
name: '',
@@ -86,6 +109,10 @@ const onFormSubmit = () => {
return;
}
auth.register(form.name, form.email, form.password);
auth.register(form.name, form.email, form.password, refUsername.value || undefined);
};
const signupWithGoogle = () => {
auth.loginWithGoogle(refUsername.value || undefined);
};
</script>

View File

@@ -97,7 +97,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
<div
v-for="signal in signalItems"
:key="signal.label"
class="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm"
class="rounded-2xl border border-slate-200 bg-white px-5 py-4"
>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ signal.label }}
@@ -211,7 +211,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</div>
<div class="grid gap-6 lg:grid-cols-3">
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-8 -258 529 532" fill="none">
<path
@@ -228,7 +228,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</p>
</article>
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-50 text-violet-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 570 570" fill="none">
<path
@@ -249,7 +249,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</p>
</article>
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-10 -226 532 468" fill="none">
<path
@@ -267,7 +267,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</article>
</div>
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 sm:p-8">
<div class="grid gap-6 lg:grid-cols-[1fr_0.9fr] lg:items-center">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">

View File

@@ -64,6 +64,11 @@ const routes: RouteData[] = [
name: "signup",
component: () => import("./auth/signup.vue"),
},
{
path: "ref/:username",
name: "referral-entry",
beforeEnter: (to) => ({ name: "signup", query: { ref: String(to.params.username || "") } }),
},
{
path: "forgot",
name: "forgot",
@@ -177,13 +182,7 @@ const routes: RouteData[] = [
},
{
path: "player",
name: "settings-player",
component: () => import("./settings/PlayerSettings/PlayerSettings.vue"),
meta: {
head: {
title: "Player Settings - Holistream",
},
},
redirect: { name: "settings-player-configs" },
},
{
path: "domains",
@@ -205,6 +204,16 @@ const routes: RouteData[] = [
},
},
},
{
path: "player-configs",
name: "settings-player-configs",
component: () => import("./settings/PlayerConfigs/PlayerConfigs.vue"),
meta: {
head: {
title: "Player Configs - Holistream",
},
},
},
{
path: "danger",
name: "settings-danger",
@@ -215,23 +224,22 @@ const routes: RouteData[] = [
},
},
},
],
},
{
path: "admin",
component: () => import("./admin/Layout.vue"),
meta: { requiresAdmin: true },
redirect: { name: "admin-overview" },
children: [
{ path: "overview", name: "admin-overview", component: () => import("./admin/Overview.vue") },
{ path: "users", name: "admin-users", component: () => import("./admin/Users.vue") },
{ path: "videos", name: "admin-videos", component: () => import("./admin/Videos.vue") },
{ path: "payments", name: "admin-payments", component: () => import("./admin/Payments.vue") },
{ path: "plans", name: "admin-plans", component: () => import("./admin/Plans.vue") },
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./admin/AdTemplates.vue") },
{ path: "jobs", name: "admin-jobs", component: () => import("./admin/Jobs.vue") },
{ path: "agents", name: "admin-agents", component: () => import("./admin/Agents.vue") },
{ path: "logs", name: "admin-logs", component: () => import("./admin/Logs.vue") },
{ path: "users", name: "admin-users", component: () => import("./settings/admin/Users.vue") },
{ path: "videos", name: "admin-videos", component: () => import("./settings/admin/Videos.vue") },
{ 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: "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") },
{ path: "logs", name: "admin-logs", component: () => import("./settings/admin/Logs.vue") },
],
},
],
},
],

View File

@@ -8,17 +8,35 @@ import NameGradient from './components/NameGradient.vue';
import QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue';
import StatsOverview from './components/StatsOverview.vue';
import type { StatProps } from '@/components/dashboard/StatsCard.vue';
import { formatBytes, isAdmin } from '@/lib/utils';
import { useTranslation } from 'i18next-vue';
import { useAuthStore } from '@/stores/auth';
const AdminOverview = defineAsyncComponent(() => import('./components/AdminOverview.vue'));
const {t} = useTranslation()
const auth = useAuthStore();
const recentVideosLoading = ref(true);
const recentVideos = ref<ModelVideo[]>([]);
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery();
const { data: usageSnapshot, isPending: isUsagePending, refresh } = useUsageQuery();
const stats = computed(() => ({
totalVideos: usageSnapshot.value?.totalVideos ?? 0,
totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
storageUsed: usageSnapshot.value?.totalStorage ?? 0,
storageLimit: 10737418240,
}));
const stats = computed<StatProps[]>(() => [
{
title: 'overview.stats.totalVideos',
value: usageSnapshot.value?.totalVideos ?? 0,
trend: { value: 12, isPositive: true }
},
{
title: 'overview.stats.totalViews',
value: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
trend: { value: 8, isPositive: true }
},
{
title: 'overview.stats.storageUsed',
value: `${formatBytes(usageSnapshot.value?.totalStorage ?? 0)} / ${t('overview.stats.unlimited')}`,
color: 'warning',
trend: { value: 5, isPositive: false }
}
]);
const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
const fetchDashboardData = async () => {
@@ -34,6 +52,7 @@ const fetchDashboardData = async () => {
};
onMounted(() => {
refresh();
fetchDashboardData();
});
</script>
@@ -44,12 +63,12 @@ onMounted(() => {
{ label: $t('pageHeader.dashboard') }
]" />
<AdminOverview v-if="isAdmin(auth.user?.role)" />
<template v-else>
<StatsOverview :loading="statsLoading" :stats="stats" />
<QuickActions :loading="recentVideosLoading" />
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
</template>
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient";
import { useQuery } from "@pinia/colada";
import { computed, onMounted, ref } from "vue";
import StatsOverview from "./StatsOverview.vue";
const error = ref<string | null>(null);
// const dashboard = ref<AdminDashboard | null>(null);
const cards = computed(() => {
const data = dashboard.value;
return [
{ title: "Total users", value: data?.totalUsers ?? 0, note: `${data?.newUsersToday ?? 0} new today`, tone: 'accent' as const },
{ title: "Total videos", value: data?.totalVideos ?? 0, note: `${data?.newVideosToday ?? 0} new today`, tone: 'success' as const },
{ title: "Payments", value: data?.totalPayments ?? 0, note: "Completed finance events", tone: 'warning' as const },
{ title: "Revenue", value: data?.totalRevenue ?? 0, note: "Lifetime gross amount", tone: 'neutral' as const },
];
});
const secondaryCards = computed(() => {
const data = dashboard.value;
return [
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0 },
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0 },
{ title: "New users today", value: data?.newUsersToday ?? 0 },
{ title: "New videos today", value: data?.newVideosToday ?? 0 },
];
});
const highlights = computed(() => {
const data = dashboard.value;
return [
{ label: "Acquisition", value: `${data?.newUsersToday ?? 0} user signups in the current day window.` },
{ label: "Content velocity", value: `${data?.newVideosToday ?? 0} newly created videos landed today.` },
{ label: "Catalog depth", value: `${data?.totalAdTemplates ?? 0} ad templates available to pair with uploads.` },
];
});
const { data: dashboard, isLoading, refresh } = useQuery({
key: () => ['admin-dashboard'],
query: () => rpcClient.getAdminDashboard(),
});
onMounted(refresh);
</script>
<template>
<StatsOverview :loading="isLoading" :stats="cards" />
<div class="mb-8">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<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/15 px-4 py-4">
<div class="text-[11px] font-medium text-foreground/55">{{ card.title }}</div>
<div class="mt-3 text-2xl font-semibold tracking-tight text-foreground">{{ isLoading ? '—' : card.value }}
</div>
</div>
</div>
<div class="rounded-lg border border-border bg-muted/15 p-4">
<div class="text-[11px] font-medium text-foreground/55">Operations notes</div>
<div class="mt-4 space-y-3">
<div v-for="item in highlights" :key="item.label"
class="rounded-2xl border border-border bg-background px-4 py-3">
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
<div class="mt-1 text-sm leading-6 text-foreground/70">{{ item.value }}</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,11 +1,10 @@
<template>
<div class="text-3xl font-bold text-gray-900 mb-1">
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || t('app.name') }) }}</span>
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || $t('app.name') }) }}</span>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
const auth = useAuthStore();
</script>

View File

@@ -34,7 +34,12 @@ const auth = useAuthStore();
const isCopied = ref(false);
const { t } = useTranslation();
const url = computed(() => `${location.origin}/ref/${auth.user?.username || ''}`);
const url = computed(() => {
if (typeof location === 'undefined') {
return auth.user?.username ? `/ref/${auth.user.username}` : '';
}
return `${location.origin}/ref/${auth.user?.username || ''}`;
});
const copyToClipboard = ($event: MouseEvent) => {
if ($event.target instanceof HTMLInputElement) {

View File

@@ -1,17 +1,11 @@
<script setup lang="ts">
import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils';
import StatsCard, { type StatProps } from '@/components/dashboard/StatsCard.vue';
import { useTranslation } from 'i18next-vue';
import { computed } from 'vue';
interface Props {
loading: boolean;
stats: {
totalVideos: number;
totalViews: number;
storageUsed: number;
storageLimit: number;
};
stats: StatProps[]
}
const props = defineProps<Props>();
@@ -21,7 +15,7 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
<template>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div v-for="i in 3" :key="i" class="bg-header rounded-xl border border-gray-200 p-6">
<div v-for="i in stats.length" :key="i" class="bg-header rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<div class="space-y-2">
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
@@ -33,12 +27,6 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
:trend="{ value: 8, isPositive: true }" />
<StatsCard :title="t('overview.stats.storageUsed')"
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
<StatsCard v-for="stat in stats" :key="stat.title" v-bind="stat"/>
</div>
</template>

View File

@@ -1,99 +1,59 @@
<script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import AppSwitch from '@/components/ui/AppSwitch.vue';
import BaseTable from '@/components/ui/BaseTable.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
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 { 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';
import { computed, ref, watch } from 'vue';
import AdsVastDialog from './components/AdsVastDialog.vue';
import AdsVastNotices from './components/AdsVastNotices.vue';
import AdsVastTable from './components/AdsVastTable.tsx';
import AdsVastToolbar from './components/AdsVastToolbar.vue';
import type {
AdTemplate,
CreateAdTemplateRequest
} from './types';
const toast = useAppToast();
const confirm = useAppConfirm();
const auth = useAuthStore();
const { t } = useTranslation();
interface VastTemplate {
id: string;
name: string;
vastUrl: string;
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
duration?: number;
enabled: boolean;
isDefault: boolean;
createdAt: string;
}
type AdTemplateApiItem = {
id?: string;
name?: string;
vastTagUrl?: string;
adFormat?: 'pre-roll' | 'mid-roll' | 'post-roll';
duration?: number | null;
isActive?: boolean;
isDefault?: boolean;
createdAt?: string;
};
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
const createInitialFormData = (): CreateAdTemplateRequest => ({
name: '',
description: '',
vastTagUrl: '',
adFormat: 'pre-roll',
duration: undefined,
isActive: true,
isDefault: false,
});
const showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null);
const editingTemplate = ref<AdTemplate | null>(null);
const saving = ref(false);
const deletingId = ref<string | null>(null);
const togglingId = ref<string | null>(null);
const defaultingId = ref<string | null>(null);
const formData = ref({
name: '',
vastUrl: '',
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
duration: undefined as number | undefined,
isDefault: false,
});
const formData = ref<CreateAdTemplateRequest>(createInitialFormData());
const isFreePlan = computed(() => !auth.user?.plan_id);
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
id: item.id || `${item.name || 'template'}:${item.vastTagUrl || item.createdAt || ''}`,
name: item.name || '',
vastUrl: item.vastTagUrl || '',
adFormat: item.adFormat || 'pre-roll',
duration: typeof item.duration === 'number' ? item.duration : undefined,
enabled: Boolean(item.isActive),
isDefault: Boolean(item.isDefault),
createdAt: item.createdAt || '',
});
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'ad-templates'],
query: async () => {
const response = await rpcClient.listAdTemplates();
return (response.templates || []).map(mapTemplate);
return response.templates || [];
},
});
const templates = computed(() => templatesSnapshot.value || []);
const templates = computed<AdTemplate[]>(() => templatesSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
const refetchTemplates = () => refetch((fetchError) => {
throw fetchError;
});
const canCreateTemplate = computed(() => !isFreePlan.value && !isInitialLoading.value && !isMutating.value);
const canEditDialog = computed(() => !isFreePlan.value && !saving.value);
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
@@ -127,13 +87,7 @@ watch(error, (value, previous) => {
});
const resetForm = () => {
formData.value = {
name: '',
vastUrl: '',
adFormat: 'pre-roll',
duration: undefined,
isDefault: false,
};
formData.value = createInitialFormData();
editingTemplate.value = null;
};
@@ -148,33 +102,40 @@ const openAddDialog = () => {
showAddDialog.value = true;
};
const openEditDialog = (template: VastTemplate) => {
if (!ensurePaidPlan()) return;
const applyTemplateToForm = (template: AdTemplate) => {
formData.value = {
name: template.name,
vastUrl: template.vastUrl,
adFormat: template.adFormat,
name: template.name || '',
description: template.description || '',
vastTagUrl: template.vastTagUrl || '',
adFormat: template.adFormat || 'pre-roll',
duration: template.duration,
isDefault: template.isDefault,
isActive: template.isActive,
isDefault: Boolean(template.isDefault),
};
};
const openEditDialog = (template: AdTemplate) => {
if (!ensurePaidPlan()) return;
applyTemplateToForm(template);
editingTemplate.value = template;
showAddDialog.value = true;
};
const buildRequestBody = (enabled = true) => ({
name: formData.value.name.trim(),
const buildRequestBody = (enabled = true): Parameters<typeof rpcClient.createAdTemplate>[0] => ({
...formData.value,
name: (formData.value.name || '').trim(),
description: '',
vastTagUrl: formData.value.vastUrl.trim(),
adFormat: formData.value.adFormat,
vastTagUrl: (formData.value.vastTagUrl || '').trim(),
adFormat: formData.value.adFormat || 'pre-roll',
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
isActive: enabled,
isDefault: enabled ? formData.value.isDefault : false,
isDefault: enabled ? Boolean(formData.value.isDefault) : false,
});
const handleSave = async () => {
if (saving.value || !ensurePaidPlan()) return;
if (!formData.value.name.trim()) {
if (!(formData.value.name || '').trim()) {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.nameRequiredSummary'),
@@ -183,7 +144,7 @@ const handleSave = async () => {
});
return;
}
if (!formData.value.vastUrl.trim()) {
if (!(formData.value.vastTagUrl || '').trim()) {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.urlRequiredSummary'),
@@ -193,7 +154,7 @@ const handleSave = async () => {
return;
}
try {
new URL(formData.value.vastUrl);
new URL(formData.value.vastTagUrl || '');
} catch {
toast.add({
severity: 'error',
@@ -217,8 +178,8 @@ const handleSave = async () => {
try {
if (editingTemplate.value) {
await rpcClient.updateAdTemplate({
id: editingTemplate.value.id,
...buildRequestBody(editingTemplate.value.enabled),
id: editingTemplate.value.id || '',
...buildRequestBody(Boolean(editingTemplate.value.isActive)),
});
toast.add({
severity: 'success',
@@ -236,7 +197,7 @@ const handleSave = async () => {
});
}
await refetchTemplates();
await refetch();
closeDialog();
} catch (value: any) {
console.error(value);
@@ -246,30 +207,30 @@ const handleSave = async () => {
}
};
const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
const handleToggle = async (template: AdTemplate, nextValue: boolean) => {
if (!ensurePaidPlan()) return;
togglingId.value = template.id;
togglingId.value = template.id || null;
try {
await rpcClient.updateAdTemplate({
id: template.id,
name: template.name,
description: '',
vastTagUrl: template.vastUrl,
id: template.id || '',
name: template.name || '',
description: template.description || '',
vastTagUrl: template.vastTagUrl || '',
adFormat: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
isActive: nextValue,
isDefault: nextValue ? template.isDefault : false,
isDefault: nextValue ? Boolean(template.isDefault) : false,
});
await refetchTemplates();
await refetch();
toast.add({
severity: 'info',
summary: nextValue
? t('settings.adsVast.toast.enabledSummary')
: t('settings.adsVast.toast.disabledSummary'),
detail: t('settings.adsVast.toast.toggleDetail', {
name: template.name,
name: template.name || '',
state: nextValue
? t('settings.adsVast.state.enabled')
: t('settings.adsVast.state.disabled'),
@@ -284,27 +245,27 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
}
};
const handleSetDefault = async (template: VastTemplate) => {
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return;
const handleSetDefault = async (template: AdTemplate) => {
if (Boolean(template.isDefault) || !Boolean(template.isActive) || !ensurePaidPlan()) return;
defaultingId.value = template.id;
defaultingId.value = template.id || null;
try {
await rpcClient.updateAdTemplate({
id: template.id,
name: template.name,
description: '',
vastTagUrl: template.vastUrl,
id: template.id || '',
name: template.name || '',
description: template.description || '',
vastTagUrl: template.vastTagUrl || '',
adFormat: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
isActive: template.enabled,
isActive: template.isActive,
isDefault: true,
});
await refetchTemplates();
await refetch();
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.defaultUpdatedSummary'),
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name }),
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name || '' }),
life: 3000,
});
} catch (value: any) {
@@ -315,19 +276,19 @@ const handleSetDefault = async (template: VastTemplate) => {
}
};
const handleDelete = (template: VastTemplate) => {
const handleDelete = (template: AdTemplate) => {
if (!ensurePaidPlan()) return;
confirm.require({
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name }),
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name || '' }),
header: t('settings.adsVast.confirm.deleteHeader'),
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
accept: async () => {
deletingId.value = template.id;
deletingId.value = template.id || null;
try {
await rpcClient.deleteAdTemplate({ id: template.id });
await refetchTemplates();
await rpcClient.deleteAdTemplate({ id: template.id || '' });
await refetch();
toast.add({
severity: 'info',
summary: t('settings.adsVast.toast.deletedSummary'),
@@ -343,158 +304,6 @@ const handleDelete = (template: VastTemplate) => {
},
});
};
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 adFormatLabels = computed(() => ({
'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 as keyof typeof adFormatLabels.value] || 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 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>
@@ -504,150 +313,36 @@ const columns = computed<ColumnDef<VastTemplate>[]>(() => [
bodyClass=""
>
<template #header-actions>
<AppButton size="sm" :disabled="isFreePlan || isInitialLoading || isMutating" @click="openAddDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ t('settings.adsVast.createTemplate') }}
</AppButton>
<AdsVastToolbar :disabled="!canCreateTemplate" @create="openAddDialog" />
</template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.adsVast.infoBanner') }}
</SettingsNotice>
<AdsVastNotices :is-free-plan="isFreePlan" />
<SettingsNotice
v-if="isFreePlan"
tone="warning"
:title="t('settings.adsVast.readOnlyTitle')"
class="rounded-none border-x-0 border-t-0 p-3"
contentClass="text-xs text-foreground/70"
>
{{ t('settings.adsVast.readOnlyMessage') }}
</SettingsNotice>
<AdsVastTable
:templates="templates"
:is-initial-loading="isInitialLoading"
:is-read-only="isFreePlan"
:is-mutating="isMutating"
:saving="saving"
:deleting-id="deletingId"
:toggling-id="togglingId"
:defaulting-id="defaultingId"
@edit="openEditDialog"
@delete="handleDelete"
@toggle-active="handleToggle($event.template, $event.value)"
@set-default="handleSetDefault"
/>
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
<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
<AdsVastDialog
:visible="showAddDialog"
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
maxWidthClass="max-w-lg"
:editing-template="editingTemplate"
:form-data="formData"
:saving="saving"
:can-edit="canEditDialog"
@update:visible="showAddDialog = $event"
@update:form-data="formData = $event"
@save="handleSave"
@close="closeDialog"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.templateName') }}</label>
<AppInput
id="name"
v-model="formData.name"
:disabled="isFreePlan || saving"
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
/>
</div>
<div class="grid gap-2">
<label for="vastUrl" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.vastUrlLabel') }}</label>
<AppInput
id="vastUrl"
v-model="formData.vastUrl"
:disabled="isFreePlan || saving"
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adFormat') }}</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="format in adFormatOptions"
:key="format"
type="button"
:disabled="isFreePlan || saving"
:class="[
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
formData.adFormat === format
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50'
]"
@click="formData.adFormat = format"
>
{{ getAdFormatLabel(format) }}
</button>
</div>
</div>
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
<label for="duration" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adInterval') }}</label>
<AppInput
id="duration"
v-model.number="formData.duration"
:disabled="isFreePlan || saving"
type="number"
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
:min="10"
:max="600"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.defaultLabel') }}</label>
<label
:class="[
'flex items-start gap-3 rounded-md border border-border p-3',
canMarkAsDefaultInDialog && !saving ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed'
]"
>
<input
v-model="formData.isDefault"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canMarkAsDefaultInDialog || saving"
>
<div>
<p class="text-sm text-foreground">{{ t('settings.adsVast.dialog.defaultCheckbox') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ editingTemplate && !editingTemplate.enabled
? t('settings.adsVast.dialog.defaultDisabledHint')
: t('settings.adsVast.dialog.defaultHint') }}
</p>
</div>
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="saving" @click="closeDialog">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" :loading="saving" :disabled="isFreePlan" @click="handleSave">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ editingTemplate ? t('settings.adsVast.dialog.update') : t('settings.adsVast.dialog.create') }}
</AppButton>
</div>
</template>
</AppDialog>
</SettingsSectionCard>
</template>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import CheckIcon from '@/components/icons/CheckIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import { useTranslation } from 'i18next-vue';
import { computed } from 'vue';
import type { AdTemplate, CreateAdTemplateRequest } from '../types';
const AD_FORMAT_OPTIONS = ['pre-roll', 'mid-roll', 'post-roll'] as const;
type AdFormatOption = NonNullable<CreateAdTemplateRequest['adFormat']>;
const props = defineProps<{
visible: boolean;
editingTemplate: AdTemplate | null;
formData: CreateAdTemplateRequest;
saving: boolean;
canEdit: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:formData', value: CreateAdTemplateRequest): void;
(e: 'save'): void;
(e: 'close'): void;
}>();
const { t } = useTranslation();
const title = computed(() => props.editingTemplate
? t('settings.adsVast.dialog.editTitle')
: t('settings.adsVast.dialog.createTitle'));
const canToggleDefault = computed(() => props.canEdit && (!props.editingTemplate || Boolean(props.editingTemplate.isActive)));
const defaultHint = computed(() => props.editingTemplate && !Boolean(props.editingTemplate.isActive)
? t('settings.adsVast.dialog.defaultDisabledHint')
: t('settings.adsVast.dialog.defaultHint'));
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 updateForm = (patch: Partial<CreateAdTemplateRequest>) => {
emit('update:formData', {
...props.formData,
...patch,
});
};
const updateTextField = (key: 'name' | 'vastTagUrl', value: string | number | null) => {
updateForm({
[key]: typeof value === 'string' ? value : value == null ? '' : String(value),
});
};
const updateDuration = (value: string | number | null) => {
if (typeof value === 'number') {
updateForm({ duration: value });
return;
}
if (value == null || value === '') {
updateForm({ duration: undefined });
return;
}
const parsed = Number(value);
updateForm({ duration: Number.isNaN(parsed) ? undefined : parsed });
};
const updateCheckbox = (event: Event) => {
updateForm({
isDefault: (event.target as HTMLInputElement).checked,
});
};
const selectAdFormat = (format: AdFormatOption) => {
updateForm({
adFormat: format,
duration: format === 'mid-roll' ? props.formData.duration : undefined,
});
};
const formatButtonClass = (format: AdFormatOption) => [
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
props.formData.adFormat === format
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50',
];
</script>
<template>
<AppDialog
:visible="visible"
:title="title"
maxWidthClass="max-w-lg"
@update:visible="emit('update:visible', $event)"
@close="emit('close')"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.templateName') }}</label>
<AppInput
id="name"
:model-value="formData.name"
:disabled="!canEdit"
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
@update:model-value="updateTextField('name', $event)"
/>
</div>
<div class="grid gap-2">
<label for="vastUrl" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.vastUrlLabel') }}</label>
<AppInput
id="vastUrl"
:model-value="formData.vastTagUrl"
:disabled="!canEdit"
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
@update:model-value="updateTextField('vastTagUrl', $event)"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adFormat') }}</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="format in AD_FORMAT_OPTIONS"
:key="format"
type="button"
:disabled="!canEdit"
:class="formatButtonClass(format)"
@click="selectAdFormat(format)"
>
{{ adFormatLabels[format] }}
</button>
</div>
</div>
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
<label for="duration" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adInterval') }}</label>
<AppInput
id="duration"
:model-value="formData.duration"
:disabled="!canEdit"
type="number"
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
:min="10"
:max="600"
@update:model-value="updateDuration"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.defaultLabel') }}</label>
<label
:class="[
'flex items-start gap-3 rounded-md border border-border p-3',
canToggleDefault && !saving ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed',
]"
>
<input
:checked="Boolean(formData.isDefault)"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canToggleDefault || saving"
@change="updateCheckbox($event)"
>
<div>
<p class="text-sm text-foreground">{{ t('settings.adsVast.dialog.defaultCheckbox') }}</p>
<p class="mt-0.5 text-xs text-foreground/60">{{ defaultHint }}</p>
</div>
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('close')">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" :loading="saving" :disabled="!canEdit" @click="emit('save')">
<template #icon>
<CheckIcon class="h-4 w-4" />
</template>
{{ editingTemplate ? t('settings.adsVast.dialog.update') : t('settings.adsVast.dialog.create') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
isFreePlan: boolean;
}>();
const { t } = useTranslation();
</script>
<template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.adsVast.infoBanner') }}
</SettingsNotice>
<SettingsNotice
v-if="isFreePlan"
tone="warning"
:title="t('settings.adsVast.readOnlyTitle')"
class="rounded-none border-x-0 border-t-0 p-3"
contentClass="text-xs text-foreground/70"
>
{{ t('settings.adsVast.readOnlyMessage') }}
</SettingsNotice>
</template>

View File

@@ -0,0 +1,252 @@
import { defineComponent, computed, type PropType } from 'vue';
import { useTranslation } from 'i18next-vue';
import { useAppToast } from '@/composables/useAppToast';
import type { ColumnDef } from '@tanstack/vue-table';
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';
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>
<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: row.original.createdAt || '-' })}
</p>
</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: '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-end gap-2">
{row.original.isDefault ? (
<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')}
</span>
) : (
<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-right text-xs font-medium uppercase tracking-wider text-foreground/50',
cellClass: 'px-6 py-3 text-right',
},
},
]);
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>
)
}}
/>
)}
</>
);
},
});

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import PlusIcon from '@/components/icons/PlusIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
disabled: boolean;
}>();
const emit = defineEmits<{
(e: 'create'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<AppButton size="sm" :disabled="disabled" @click="emit('create')">
<template #icon>
<PlusIcon class="h-4 w-4" />
</template>
{{ t('settings.adsVast.createTemplate') }}
</AppButton>
</template>

View File

@@ -0,0 +1,6 @@
export type { AdTemplate } from '@/server/gen/proto/app/v1/common';
export type {
CreateAdTemplateRequest,
DeleteAdTemplateRequest,
UpdateAdTemplateRequest,
} from '@/server/gen/proto/app/v1/catalog';

View File

@@ -7,11 +7,11 @@ import { useAppToast } from '@/composables/useAppToast';
import { useUsageQuery } from '@/composables/useUsageQuery';
import { formatBytes } from '@/lib/utils';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
import BillingTopupDialog from '@/routes/settings/components/billing/BillingTopupDialog.vue';
import BillingUsageSection from '@/routes/settings/components/billing/BillingUsageSection.vue';
import BillingWalletRow from '@/routes/settings/components/billing/BillingWalletRow.vue';
import BillingHistorySection from '@/routes/settings/Billing/components/BillingHistorySection.vue';
import BillingPlansSection from '@/routes/settings/Billing/components/BillingPlansSection.vue';
import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
import BillingWalletRow from '@/routes/settings/Billing/components/BillingWalletRow.vue';
import type { Plan as ModelPlan, PaymentHistoryItem as PaymentHistoryApiItem } from '@/server/gen/proto/app/v1/common';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';

View File

@@ -16,7 +16,6 @@ defineProps<{
hint: string;
cancelLabel: string;
proceedLabel: string;
formatMoney: (amount: number) => string;
}>();
const emit = defineEmits<{
@@ -44,14 +43,14 @@ const emit = defineEmits<{
v-for="preset in presets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
'py-2 px-3 rounded-md bg-header text-sm font-medium transition-all hover:bg-gray-500',
amount === preset
? 'bg-primary text-primary-foreground'
? 'bg-primary text-white'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="emit('selectPreset', preset)"
>
{{ formatMoney(preset) }}
${{ preset }}
</button>
</div>

View File

@@ -2,7 +2,7 @@
import AppButton from '@/components/ui/AppButton.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import SettingsRow from '../SettingsRow.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
defineProps<{
title: string;

View File

@@ -1,68 +1,28 @@
<script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import BaseTable from '@/components/ui/BaseTable.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
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';
import DomainsDnsDialog from './components/DomainsDnsDialog.vue';
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';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
type DomainApiItem = {
id?: string;
name?: string;
created_at?: string;
};
type DomainItem = {
id: string;
name: string;
addedAt: string;
};
const newDomain = ref('');
const showAddDialog = ref(false);
const adding = ref(false);
const removingId = ref<string | null>(null);
const normalizeDomainInput = (value: string) => value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/$/, '');
const formatDate = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.split('T')[0] || value;
}
return date.toISOString().split('T')[0];
};
const mapDomainItem = (item: DomainApiItem): DomainItem => ({
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
name: item.name || '',
addedAt: formatDate(item.created_at),
});
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'domains'],
query: async () => {
@@ -73,13 +33,8 @@ const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
const domains = computed(() => domainsSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
const refetchDomains = () => refetch((fetchError) => {
throw fetchError;
});
watch(error, (value, previous) => {
if (!value || value === previous || adding.value || removingId.value !== null) return;
@@ -105,7 +60,7 @@ const handleAddDomain = async () => {
if (adding.value) return;
const domainName = normalizeDomainInput(newDomain.value);
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
if (!domainName || !domainName.includes('.') || /[/\s]/.test(domainName)) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.invalidSummary'),
@@ -132,7 +87,7 @@ const handleAddDomain = async () => {
name: domainName,
});
await refetchDomains();
await refetch();
closeAddDialog();
toast.add({
severity: 'success',
@@ -181,7 +136,7 @@ const handleRemoveDomain = (domain: DomainItem) => {
removingId.value = domain.id;
try {
await rpcClient.deleteDomain({ id: domain.id });
await refetchDomains();
await refetch();
toast.add({
severity: 'info',
summary: t('settings.domainsDns.toast.removedSummary'),
@@ -222,49 +177,6 @@ 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>
@@ -274,96 +186,33 @@ const columns = computed<ColumnDef<DomainItem>[]>(() => [
bodyClass=""
>
<template #header-actions>
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.domainsDns.infoBanner') }}
</SettingsNotice>
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
<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">
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.copyCode') }}
</AppButton>
</div>
<p class="text-xs text-foreground/60 mb-2">
{{ t('settings.domainsDns.embedCodeHint') }}
</p>
<pre class="bg-header border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ iframeCode }}</code></pre>
</div>
<AppDialog
:visible="showAddDialog"
:title="t('settings.domainsDns.dialog.title')"
maxWidthClass="max-w-md"
@update:visible="showAddDialog = $event"
@close="closeAddDialog"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
<AppInput
id="domain"
v-model="newDomain"
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
@enter="handleAddDomain"
<DomainsDnsToolbar
:loading="adding"
:disabled="isInitialLoading || removingId !== null"
@create="openAddDialog"
/>
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
</div>
<SettingsNotice
tone="warning"
:title="t('settings.domainsDns.dialog.importantTitle')"
class="p-3"
>
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
</SettingsNotice>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</div>
</template>
</AppDialog>
<DomainsDnsNotices />
<DomainsDnsTable
:domains="domains"
:is-initial-loading="isInitialLoading"
:adding="adding"
:removing-id="removingId"
@remove="handleRemoveDomain"
/>
<DomainsDnsEmbedCode :code="iframeCode" @copy="copyIframeCode" />
<DomainsDnsDialog
:visible="showAddDialog"
:domain="newDomain"
:adding="adding"
@update:visible="showAddDialog = $event"
@update:domain="newDomain = $event"
@submit="handleAddDomain"
@close="closeAddDialog"
/>
</SettingsSectionCard>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import CheckIcon from '@/components/icons/CheckIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import { useTranslation } from 'i18next-vue';
const props = defineProps<{
visible: boolean;
domain: string;
adding: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:domain', value: string): void;
(e: 'submit'): void;
(e: 'close'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<AppDialog
:visible="visible"
:title="t('settings.domainsDns.dialog.title')"
maxWidthClass="max-w-md"
@update:visible="emit('update:visible', $event)"
@close="emit('close')"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
<AppInput
id="domain"
:model-value="domain"
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
@update:model-value="emit('update:domain', String($event ?? ''))"
@enter="emit('submit')"
/>
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
</div>
<SettingsNotice
tone="warning"
:title="t('settings.domainsDns.dialog.importantTitle')"
class="p-3"
>
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
</SettingsNotice>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="adding" @click="emit('close')">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" :loading="adding" @click="emit('submit')">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import CheckIcon from '@/components/icons/CheckIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
code: string;
}>();
const emit = defineEmits<{
(e: 'copy'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<div class="px-6 py-4 bg-muted/30">
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
<AppButton variant="secondary" size="sm" @click="emit('copy')">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.copyCode') }}
</AppButton>
</div>
<p class="mb-2 text-xs text-foreground/60">
{{ t('settings.domainsDns.embedCodeHint') }}
</p>
<pre class="overflow-x-auto rounded-md border border-border bg-header p-3 text-xs text-foreground/70"><code>{{ code }}</code></pre>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import { useTranslation } from 'i18next-vue';
const { t } = useTranslation();
</script>
<template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.domainsDns.infoBanner') }}
</SettingsNotice>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
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 SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
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[];
isInitialLoading: boolean;
adding: boolean;
removingId: string | null;
}>();
const emit = defineEmits<{
(e: 'remove', domain: DomainItem): void;
}>();
const { t } = useTranslation();
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: props.adding || props.removingId !== null,
onClick: () => emit('remove', 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>
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
<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>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import PlusIcon from '@/components/icons/PlusIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
disabled: boolean;
loading: boolean;
}>();
const emit = defineEmits<{
(e: 'create'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<AppButton size="sm" :loading="loading" :disabled="disabled" @click="emit('create')">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</template>

View File

@@ -0,0 +1,25 @@
import type { DomainApiItem, DomainItem } from './types';
export const normalizeDomainInput = (value: string) => value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/$/, '');
export const formatDate = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.split('T')[0] || value;
}
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),
});

View File

@@ -0,0 +1,11 @@
export type DomainApiItem = {
id?: string;
name?: string;
created_at?: string;
};
export type DomainItem = {
id: string;
name: string;
addedAt: string;
};

View File

@@ -65,10 +65,6 @@ const notificationTypes = computed(() => [
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
const refetchPreferences = () => refetch((fetchError) => {
throw fetchError;
});
watch(preferencesSnapshot, (snapshot) => {
if (!snapshot) return;
notificationSettings.value = createNotificationSettingsDraft(snapshot);
@@ -93,7 +89,7 @@ const handleSave = async () => {
await rpcClient.updatePreferences(
toNotificationPreferencesPayload(notificationSettings.value),
);
await refetchPreferences();
await refetch();
toast.add({
severity: 'success',

View File

@@ -0,0 +1,402 @@
<script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
import PlayerConfigDialog from './components/PlayerConfigDialog.vue';
import PlayerConfigsNotices from './components/PlayerConfigsNotices.vue';
import PlayerConfigsTable from './components/PlayerConfigsTable.vue';
import PlayerConfigsToolbar from './components/PlayerConfigsToolbar.vue';
import type { PlayerConfig, PlayerConfigApiItem, PlayerConfigFormData } from './types';
const toast = useAppToast();
const confirm = useAppConfirm();
const auth = useAuthStore();
const { t } = useTranslation();
const createInitialFormData = (): PlayerConfigFormData => ({
name: '',
description: '',
autoplay: false,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
chromecast: true,
encrytionM3u8: true,
logoUrl: '',
isDefault: false,
});
const showAddDialog = ref(false);
const editingConfig = ref<PlayerConfig | null>(null);
const saving = ref(false);
const deletingId = ref<string | null>(null);
const togglingId = ref<string | null>(null);
const defaultingId = ref<string | null>(null);
const formData = ref<PlayerConfigFormData>(createInitialFormData());
const FREE_PLAN_LIMIT_MESSAGE = 'Free plan supports only 1 player config';
const FREE_PLAN_RECONCILIATION_MESSAGE = 'Delete extra player configs to continue managing player configs on the free plan';
const isFreePlan = computed(() => !auth.user?.plan_id);
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
const mapConfig = (item: PlayerConfigApiItem): PlayerConfig => ({
id: item.id || `${item.name || 'config'}:${item.createdAt || ''}`,
name: item.name || '',
description: item.description || undefined,
autoplay: Boolean(item.autoplay),
loop: Boolean(item.loop),
muted: Boolean(item.muted),
showControls: item.showControls !== false,
pip: item.pip !== false,
airplay: item.airplay !== false,
chromecast: item.chromecast !== false,
encrytionM3u8: item.encrytionM3u8 !== false,
logoUrl: item.logoUrl || undefined,
isActive: item.isActive !== false,
isDefault: Boolean(item.isDefault),
createdAt: item.createdAt || '',
});
const { data: configsSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'player-configs'],
query: async () => {
const response = await rpcClient.listPlayerConfigs();
return (response.configs || []).map(mapConfig);
},
});
const configs = computed(() => configsSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !configsSnapshot.value);
const configCount = computed(() => configs.value.length);
const hasExactlyOneConfig = computed(() => configCount.value === 1);
const isFreeReconciliationMode = computed(() => isFreePlan.value && configCount.value > 1);
const canCreateConfig = computed(() => !isInitialLoading.value && !isMutating.value && (!isFreePlan.value || configCount.value === 0));
const canManageExistingConfig = computed(() => !isMutating.value && (!isFreePlan.value || hasExactlyOneConfig.value));
const canDeleteConfig = computed(() => !isMutating.value);
const canEditDialog = computed(() => !saving.value && (!isFreePlan.value || hasExactlyOneConfig.value));
const canSubmitDialog = computed(() => editingConfig.value ? canManageExistingConfig.value : canCreateConfig.value);
// const refetchConfigs = () => refetch((fetchError) => {
// throw fetchError;
// });
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
const showQuotaToast = (key: 'limit' | 'reconciliation') => {
toast.add({
severity: 'warn',
summary: t(`settings.playerConfigs.toast.${key}Summary`),
detail: t(`settings.playerConfigs.toast.${key}Detail`),
life: 4000,
});
};
const showActionErrorToast = (value: any) => {
const message = getErrorMessage(value, t('settings.playerConfigs.toast.failedDetail'));
if (message === FREE_PLAN_LIMIT_MESSAGE) {
showQuotaToast('limit');
return;
}
if (message === FREE_PLAN_RECONCILIATION_MESSAGE) {
showQuotaToast('reconciliation');
return;
}
toast.add({
severity: 'error',
summary: t('settings.playerConfigs.toast.failedSummary'),
detail: message,
life: 5000,
});
};
const ensureCanCreateConfig = () => {
if (canCreateConfig.value) return true;
if (isFreePlan.value && configCount.value >= 1) {
showQuotaToast('limit');
}
return false;
};
const ensureCanManageExistingConfig = () => {
if (canManageExistingConfig.value) return true;
if (isFreeReconciliationMode.value) {
showQuotaToast('reconciliation');
}
return false;
};
watch(error, (value, previous) => {
if (!value || value === previous || isMutating.value) return;
showActionErrorToast(value);
});
const resetForm = () => {
formData.value = createInitialFormData();
editingConfig.value = null;
};
const closeDialog = () => {
showAddDialog.value = false;
resetForm();
};
const openAddDialog = () => {
if (!ensureCanCreateConfig()) return;
resetForm();
showAddDialog.value = true;
};
const applyConfigToForm = (config: PlayerConfig) => {
formData.value = {
name: config.name,
description: config.description || '',
autoplay: config.autoplay,
loop: config.loop,
muted: config.muted,
showControls: config.showControls,
pip: config.pip,
airplay: config.airplay,
chromecast: config.chromecast,
encrytionM3u8: config.encrytionM3u8,
logoUrl: config.logoUrl || '',
isDefault: config.isDefault,
};
};
const openEditDialog = (config: PlayerConfig) => {
if (!ensureCanManageExistingConfig()) return;
applyConfigToForm(config);
editingConfig.value = config;
showAddDialog.value = true;
};
const buildRequestBody = (enabled = true) => ({
name: formData.value.name.trim(),
description: formData.value.description.trim() || undefined,
autoplay: formData.value.autoplay,
loop: formData.value.loop,
muted: formData.value.muted,
showControls: formData.value.showControls,
pip: formData.value.pip,
airplay: formData.value.airplay,
chromecast: formData.value.chromecast,
encrytionM3u8: formData.value.encrytionM3u8,
logoUrl: formData.value.logoUrl.trim() || undefined,
isActive: enabled,
isDefault: enabled ? formData.value.isDefault : false,
});
const handleSave = async () => {
if (saving.value) return;
if (editingConfig.value) {
if (!ensureCanManageExistingConfig()) return;
} else if (!ensureCanCreateConfig()) {
return;
}
if (!formData.value.name.trim()) {
toast.add({
severity: 'error',
summary: t('settings.playerConfigs.toast.nameRequiredSummary'),
detail: t('settings.playerConfigs.toast.nameRequiredDetail'),
life: 3000,
});
return;
}
saving.value = true;
try {
if (editingConfig.value) {
await rpcClient.updatePlayerConfig({
id: editingConfig.value.id,
...buildRequestBody(editingConfig.value.isActive),
});
toast.add({
severity: 'success',
summary: t('settings.playerConfigs.toast.updatedSummary'),
detail: t('settings.playerConfigs.toast.updatedDetail'),
life: 3000,
});
} else {
await rpcClient.createPlayerConfig(buildRequestBody(true));
toast.add({
severity: 'success',
summary: t('settings.playerConfigs.toast.createdSummary'),
detail: t('settings.playerConfigs.toast.createdDetail'),
life: 3000,
});
}
await refetch();
closeDialog();
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
saving.value = false;
}
};
const handleToggle = async (config: PlayerConfig, nextValue: boolean) => {
if (!ensureCanManageExistingConfig()) return;
togglingId.value = config.id;
try {
await rpcClient.updatePlayerConfig({
id: config.id,
name: config.name,
description: config.description,
autoplay: config.autoplay,
loop: config.loop,
muted: config.muted,
showControls: config.showControls,
pip: config.pip,
airplay: config.airplay,
chromecast: config.chromecast,
encrytionM3u8: config.encrytionM3u8,
logoUrl: config.logoUrl,
isActive: nextValue,
isDefault: nextValue ? config.isDefault : false,
});
await refetch();
toast.add({
severity: 'info',
summary: nextValue
? t('settings.playerConfigs.toast.enabledSummary')
: t('settings.playerConfigs.toast.disabledSummary'),
detail: t('settings.playerConfigs.toast.toggleDetail', {
name: config.name,
state: nextValue
? t('settings.playerConfigs.state.enabled')
: t('settings.playerConfigs.state.disabled'),
}),
life: 2000,
});
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
togglingId.value = null;
}
};
const handleSetDefault = async (config: PlayerConfig) => {
if (config.isDefault || !config.isActive || !ensureCanManageExistingConfig()) return;
defaultingId.value = config.id;
try {
await rpcClient.updatePlayerConfig({
id: config.id,
name: config.name,
description: config.description,
autoplay: config.autoplay,
loop: config.loop,
muted: config.muted,
showControls: config.showControls,
pip: config.pip,
airplay: config.airplay,
chromecast: config.chromecast,
encrytionM3u8: config.encrytionM3u8,
logoUrl: config.logoUrl,
isActive: config.isActive,
isDefault: true,
});
await refetch();
toast.add({
severity: 'success',
summary: t('settings.playerConfigs.toast.defaultUpdatedSummary'),
detail: t('settings.playerConfigs.toast.defaultUpdatedDetail', { name: config.name }),
life: 3000,
});
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
defaultingId.value = null;
}
};
const handleDelete = (config: PlayerConfig) => {
if (!canDeleteConfig.value) return;
confirm.require({
message: t('settings.playerConfigs.confirm.deleteMessage', { name: config.name }),
header: t('settings.playerConfigs.confirm.deleteHeader'),
acceptLabel: t('settings.playerConfigs.confirm.deleteAccept'),
rejectLabel: t('settings.playerConfigs.confirm.deleteReject'),
accept: async () => {
deletingId.value = config.id;
try {
await rpcClient.deletePlayerConfig({ id: config.id });
await refetch();
toast.add({
severity: 'info',
summary: t('settings.playerConfigs.toast.deletedSummary'),
detail: t('settings.playerConfigs.toast.deletedDetail'),
life: 3000,
});
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
deletingId.value = null;
}
},
});
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.playerConfigs.title')"
:description="t('settings.content.playerConfigs.subtitle')"
bodyClass=""
>
<template #header-actions>
<PlayerConfigsToolbar :can-create-config="canCreateConfig" @create="openAddDialog" />
</template>
<PlayerConfigsNotices
:is-free-plan="isFreePlan"
:is-free-reconciliation-mode="isFreeReconciliationMode"
/>
<PlayerConfigsTable
:configs="configs"
:is-initial-loading="isInitialLoading"
:can-manage-existing-config="canManageExistingConfig"
:can-delete-config="canDeleteConfig"
:saving="saving"
:deleting-id="deletingId"
:toggling-id="togglingId"
:defaulting-id="defaultingId"
@edit="openEditDialog"
@delete="handleDelete"
@toggle-active="handleToggle($event.config, $event.value)"
@set-default="handleSetDefault"
/>
<PlayerConfigDialog
:visible="showAddDialog"
:editing-config="editingConfig"
:form-data="formData"
:saving="saving"
:can-edit-dialog="canEditDialog"
:can-submit="canSubmitDialog"
@update:visible="showAddDialog = $event"
@update:form-data="formData = $event"
@save="handleSave"
@close="closeDialog"
/>
</SettingsSectionCard>
</template>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import CheckIcon from '@/components/icons/CheckIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import { useTranslation } from 'i18next-vue';
import { computed } from 'vue';
import type { PlayerConfigFormData } from '../types';
type FormBooleanKey =
| 'autoplay'
| 'loop'
| 'muted'
| 'showControls'
| 'pip'
| 'airplay'
| 'chromecast'
| 'encrytionM3u8'
| 'isDefault';
const props = defineProps<{
visible: boolean;
editingConfig: { isActive: boolean } | null;
formData: PlayerConfigFormData;
saving: boolean;
canEditDialog: boolean;
canSubmit: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:formData', value: PlayerConfigFormData): void;
(e: 'save'): void;
(e: 'close'): void;
}>();
const { t } = useTranslation();
const title = computed(() => props.editingConfig
? t('settings.playerConfigs.dialog.editTitle')
: t('settings.playerConfigs.dialog.createTitle'));
const canToggleDefault = computed(() => props.canEditDialog && (!props.editingConfig || props.editingConfig.isActive));
const defaultHint = computed(() => props.editingConfig && !props.editingConfig.isActive
? t('settings.playerConfigs.dialog.defaultDisabledHint')
: t('settings.playerConfigs.dialog.defaultHint'));
const updateTextField = (key: 'name' | 'description' | 'logoUrl', value: string | number | null) => {
emit('update:formData', {
...props.formData,
[key]: typeof value === 'string' ? value : value == null ? '' : String(value),
});
};
const updateCheckboxField = (key: FormBooleanKey, event: Event) => {
emit('update:formData', {
...props.formData,
[key]: (event.target as HTMLInputElement).checked,
});
};
const optionCardClass = (disabled: boolean) => [
'flex items-start gap-3 rounded-md border border-border p-3 transition-colors',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-primary/50',
];
</script>
<template>
<AppDialog
:visible="visible"
:title="title"
maxWidthClass="max-w-2xl"
@update:visible="emit('update:visible', $event)"
@close="emit('close')"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.name') }}</label>
<AppInput
id="name"
:model-value="formData.name"
:disabled="!canEditDialog"
:placeholder="t('settings.playerConfigs.dialog.namePlaceholder')"
@update:model-value="updateTextField('name', $event)"
/>
</div>
<div class="grid gap-2">
<label for="description" class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.description') }}</label>
<AppInput
id="description"
:model-value="formData.description"
:disabled="!canEditDialog"
:placeholder="t('settings.playerConfigs.dialog.descriptionPlaceholder')"
@update:model-value="updateTextField('description', $event)"
/>
</div>
<div class="grid gap-3">
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.playbackOptions') }}</label>
<div class="grid grid-cols-2 gap-3">
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.autoplay"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('autoplay', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.autoplay.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.autoplay.description') }}</p>
</div>
</label>
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.loop"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('loop', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.loop.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.loop.description') }}</p>
</div>
</label>
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.muted"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('muted', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.muted.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.muted.description') }}</p>
</div>
</label>
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.showControls"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('showControls', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.showControls.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.showControls.description') }}</p>
</div>
</label>
</div>
</div>
<div class="grid gap-3">
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.castingOptions') }}</label>
<div class="grid grid-cols-3 gap-3">
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.pip"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('pip', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.pip.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.pip.description') }}</p>
</div>
</label>
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.airplay"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('airplay', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.airplay.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.airplay.description') }}</p>
</div>
</label>
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.chromecast"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('chromecast', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.chromecast.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.chromecast.description') }}</p>
</div>
</label>
</div>
</div>
<div class="grid gap-3">
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.advancedOptions') }}</label>
<div class="grid grid-cols-1 gap-3">
<label :class="optionCardClass(!canEditDialog)">
<input
:checked="formData.encrytionM3u8"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canEditDialog"
@change="updateCheckboxField('encrytionM3u8', $event)"
/>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.encrytionM3u8.title') }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.encrytionM3u8.description') }}</p>
</div>
</label>
<div class="grid gap-2 rounded-md border border-border p-3">
<label for="logoUrl" class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.logoUrl') }}</label>
<AppInput
id="logoUrl"
:model-value="formData.logoUrl"
:disabled="!canEditDialog"
:placeholder="t('settings.playerConfigs.dialog.logoUrlPlaceholder')"
@update:model-value="updateTextField('logoUrl', $event)"
/>
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.dialog.logoUrlHint') }}</p>
</div>
</div>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.defaultLabel') }}</label>
<label
:class="[
'flex items-start gap-3 rounded-md border border-border p-3',
canToggleDefault && !saving ? 'cursor-pointer hover:border-primary/50' : 'opacity-60 cursor-not-allowed',
]"
>
<input
:checked="formData.isDefault"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
:disabled="!canToggleDefault || saving"
@change="updateCheckboxField('isDefault', $event)"
/>
<div>
<p class="text-sm text-foreground">{{ t('settings.playerConfigs.dialog.defaultCheckbox') }}</p>
<p class="mt-0.5 text-xs text-foreground/60">{{ defaultHint }}</p>
</div>
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('close')">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" :loading="saving" :disabled="!canSubmit" @click="emit('save')">
<template #icon>
<CheckIcon class="h-4 w-4" />
</template>
{{ editingConfig ? t('settings.playerConfigs.dialog.update') : t('settings.playerConfigs.dialog.create') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PlayerConfig } from '../types';
import { computed } from 'vue';
import { useTranslation } from 'i18next-vue';
const props = defineProps<{
config: PlayerConfig;
}>();
const { t } = useTranslation();
const badges = computed(() => {
const values: Array<{ label: string; color: string }> = [];
if (props.config.autoplay) values.push({ label: t('settings.playerConfigs.badges.autoplay'), color: 'bg-blue-500/10 text-blue-500' });
if (props.config.loop) values.push({ label: t('settings.playerConfigs.badges.loop'), color: 'bg-green-500/10 text-green-500' });
if (props.config.muted) values.push({ label: t('settings.playerConfigs.badges.muted'), color: 'bg-yellow-500/10 text-yellow-500' });
if (props.config.showControls) values.push({ label: t('settings.playerConfigs.badges.controls'), color: 'bg-purple-500/10 text-purple-500' });
if (props.config.pip) values.push({ label: t('settings.playerConfigs.badges.pip'), color: 'bg-pink-500/10 text-pink-500' });
if (props.config.airplay) values.push({ label: t('settings.playerConfigs.badges.airplay'), color: 'bg-indigo-500/10 text-indigo-500' });
if (props.config.chromecast) values.push({ label: t('settings.playerConfigs.badges.chromecast'), color: 'bg-red-500/10 text-red-500' });
if (props.config.encrytionM3u8) values.push({ label: t('settings.playerConfigs.badges.encrytionM3u8'), color: 'bg-amber-500/10 text-amber-500' });
if (props.config.logoUrl) values.push({ label: t('settings.playerConfigs.badges.logo'), color: 'bg-sky-500/10 text-sky-500' });
return values;
});
</script>
<template>
<div class="flex max-w-[280px] flex-wrap gap-1">
<span
v-for="badge in badges.slice(0, 4)"
:key="badge.label"
:class="['rounded px-1.5 py-0.5 text-xs font-medium', badge.color]"
>
{{ badge.label }}
</span>
<span v-if="badges.length > 4" class="text-xs text-foreground/50">+{{ badges.length - 4 }}</span>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
isFreePlan: boolean;
isFreeReconciliationMode: boolean;
}>();
const { t } = useTranslation();
</script>
<template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.playerConfigs.infoBanner') }}
</SettingsNotice>
<SettingsNotice
v-if="isFreePlan"
tone="warning"
:title="t(isFreeReconciliationMode ? 'settings.playerConfigs.reconciliationTitle' : 'settings.playerConfigs.freePlanTitle')"
class="rounded-none border-x-0 border-t-0 p-3"
contentClass="text-xs text-foreground/70"
>
{{ t(isFreeReconciliationMode ? 'settings.playerConfigs.reconciliationMessage' : 'settings.playerConfigs.freePlanMessage') }}
</SettingsNotice>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
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 type { ColumnDef } from '@tanstack/vue-table';
import { useTranslation } from 'i18next-vue';
import { computed, h } from 'vue';
import PlayerConfigSettingsBadges from './PlayerConfigSettingsBadges.vue';
import type { PlayerConfig } from '../types';
const props = defineProps<{
configs: PlayerConfig[];
isInitialLoading: boolean;
canManageExistingConfig: boolean;
canDeleteConfig: boolean;
saving: boolean;
deletingId: string | null;
togglingId: string | null;
defaultingId: string | null;
}>();
const emit = defineEmits<{
(e: 'edit', config: PlayerConfig): void;
(e: 'delete', config: PlayerConfig): void;
(e: 'toggle-active', payload: { config: PlayerConfig; value: boolean }): void;
(e: 'set-default', config: PlayerConfig): void;
}>();
const { t } = useTranslation();
const columns = computed<ColumnDef<PlayerConfig>[]>(() => [
{
id: 'config',
header: t('settings.playerConfigs.table.name'),
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 cursor-pointer hover:underline', onClick: () => emit('edit', row.original) }, 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.playerConfigs.defaultBadge'))
: null,
]),
row.original.description
? h('p', { class: 'mt-0.5 text-xs text-foreground/50' }, row.original.description)
: h('p', { class: 'mt-0.5 text-xs text-foreground/40' }, t('settings.playerConfigs.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: 'settings',
header: t('settings.playerConfigs.table.settings'),
accessorFn: row => [
row.autoplay ? 'autoplay' : '',
row.loop ? 'loop' : '',
row.muted ? 'muted' : '',
row.showControls ? 'controls' : '',
row.pip ? 'pip' : '',
row.airplay ? 'airplay' : '',
row.chromecast ? 'chromecast' : '',
row.encrytionM3u8 ? 'encrytionM3u8' : '',
row.logoUrl ? 'logo' : '',
].filter(Boolean).join(', '),
cell: ({ row }) => h(PlayerConfigSettingsBadges, { config: row.original }),
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.isActive),
cell: ({ row }) => h('div', { class: 'text-center' }, [
h(AppSwitch, {
modelValue: row.original.isActive,
disabled: !props.canManageExistingConfig || props.saving || props.deletingId !== null || props.defaultingId !== null || props.togglingId === row.original.id,
'onUpdate:modelValue': (value: boolean) => emit('toggle-active', { config: 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.playerConfigs.actions.default'))
: h(AppButton, {
variant: 'ghost',
size: 'sm',
loading: props.defaultingId === row.original.id,
disabled: !props.canManageExistingConfig || props.saving || props.deletingId !== null || props.togglingId !== null || props.defaultingId !== null || !row.original.isActive,
onClick: () => emit('set-default', row.original),
}, () => t('settings.playerConfigs.actions.setDefault')),
h(AppButton, {
variant: 'ghost',
size: 'sm',
disabled: !props.canManageExistingConfig,
onClick: () => emit('edit', row.original),
}, {
icon: () => h(PencilIcon, { class: 'h-4 w-4' }),
}),
h(AppButton, {
variant: 'ghost',
size: 'sm',
disabled: !props.canDeleteConfig,
onClick: () => emit('delete', row.original),
}, {
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
}),
]),
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-right',
},
},
]);
</script>
<template>
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
<BaseTable
v-else
:data="configs"
: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.playerConfigs.emptyTitle') }}</p>
<p class="text-xs text-foreground/40">{{ t('settings.playerConfigs.emptySubtitle') }}</p>
</div>
</template>
</BaseTable>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import PlusIcon from '@/components/icons/PlusIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
canCreateConfig: boolean;
}>();
const emit = defineEmits<{
(e: 'create'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<AppButton size="sm" :disabled="!canCreateConfig" @click="emit('create')">
<template #icon>
<PlusIcon class="h-4 w-4" />
</template>
{{ t('settings.playerConfigs.createConfig') }}
</AppButton>
</template>

View File

@@ -0,0 +1,50 @@
export interface PlayerConfig {
id: string;
name: string;
description?: string;
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
encrytionM3u8: boolean;
logoUrl?: string;
isActive: boolean;
isDefault: boolean;
createdAt: string;
}
export type PlayerConfigApiItem = {
id?: string;
name?: string;
description?: string | null;
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
showControls?: boolean | null;
pip?: boolean | null;
airplay?: boolean | null;
chromecast?: boolean | null;
encrytionM3u8?: boolean | null;
logoUrl?: string | null;
isActive?: boolean | null;
isDefault?: boolean;
createdAt?: string;
};
export interface PlayerConfigFormData {
name: string;
description: string;
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
encrytionM3u8: boolean;
logoUrl: string;
isDefault: boolean;
}

View File

@@ -1,166 +1,14 @@
<script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/ui/AppButton.vue';
import AppSwitch from '@/components/ui/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import {
createPlayerSettingsDraft,
toPlayerPreferencesPayload,
useSettingsPreferencesQuery,
} from '@/composables/useSettingsPreferencesQuery';
import { useAppToast } from '@/composables/useAppToast';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsRowSkeleton from '@/routes/settings/components/SettingsRowSkeleton.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
const toast = useAppToast();
const { t } = useTranslation();
const router = useRouter();
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
const playerSettings = ref(createPlayerSettingsDraft());
const saving = ref(false);
const settingsItems = computed(() => [
{
key: 'autoplay' as const,
title: 'settings.playerSettings.items.autoplay.title',
description: 'settings.playerSettings.items.autoplay.description',
svg: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 404"><path d="M26 45v314c0 10 9 19 19 19 5 0 9-2 13-5l186-157c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19z" class="fill-primary/30"/><path d="M26 359c0 11 9 19 19 19 5 0 9-2 13-4l186-158c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19v314zm-16 0V45c0-19 16-35 35-35 8 0 17 3 23 8l186 158c8 6 12 16 12 26s-4 20-12 26L68 386c-6 5-15 8-23 8-19 0-35-16-35-35zM378 18v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8zm144 0v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8z" class="fill-primary"/></svg>`,
},
{
key: 'loop' as const,
title: 'settings.playerSettings.items.loop.title',
description: 'settings.playerSettings.items.loop.description',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497 496"><path d="M80 248c0 30 8 58 21 82h65c8 0 14 6 14 14s-6 14-14 14h-45c31 36 76 58 127 58 93 0 168-75 168-168 0-30-8-58-21-82h-65c-8 0-14-6-14-14s6-14 14-14h45c-31-35-76-58-127-58-93 0-168 75-168 168z" class="fill-primary/30"/><path d="M70 358c37 60 103 100 179 100 116 0 210-94 210-210 0-8 6-14 14-14 7 0 14 6 14 14 0 131-107 238-238 238-82 0-154-41-197-104v90c0 8-6 14-14 14s-14-6-14-14V344c0-8 6-14 14-14h128c8 0 14 6 14 14s-6 14-14 14H70zm374-244V24c0-8 6-14 14-14s14 6 14 14v128c0 8-6 14-14 14H330c-8 0-14-6-14-14s6-14 14-14h96C389 78 323 38 248 38 132 38 38 132 38 248c0 8-7 14-14 14-8 0-14-6-14-14C10 117 116 10 248 10c81 0 153 41 196 104z" class="fill-primary"/></svg>`,
},
{
key: 'muted' as const,
title: 'settings.playerSettings.items.muted.title',
description: 'settings.playerSettings.items.muted.description',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 549 468"><path d="M26 186v96c0 18 14 32 32 32h64c4 0 8 2 11 5l118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32z" class="fill-primary/30"/><path d="m133 319 118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32v96c0 18 14 32 32 32h64c4 0 8 2 11 5zM58 138h64L240 20c7-6 15-10 24-10 19 0 34 15 34 34v380c0 19-15 34-34 34-9 0-17-4-24-10L122 330H58c-26 0-48-21-48-48v-96c0-26 22-48 48-48zm322 18c3-3 9-3 12 0l66 67 66-67c3-3 8-3 12 0 3 3 3 9 0 12l-67 66 67 66c3 3 3 8 0 12-4 3-9 3-12 0l-66-67-66 67c-3 3-9 3-12 0-3-4-3-9 0-12l67-66-67-66c-3-3-3-9 0-12z" class="fill-primary"/></svg>`,
},
{
key: 'showControls' as const,
title: 'settings.playerSettings.items.showControls.title',
description: 'settings.playerSettings.items.showControls.description',
svg: `<svg class="h6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M26 74v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74c0-26-21-48-48-48H74c-26 0-48 22-48 48zm48 72c0-4 4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8zm0 176c0-4 4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8s-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8z" class="fill-primary/30"/><path d="M442 74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74zm16 320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h320c35 0 64 29 64 64v320zm-64-72c0 4-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8zm-112 0v32c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16v32zm104-184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184zm-232-24v64c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16z" class="fill-primary"/></svg>`,
},
{
key: 'pip' as const,
title: 'settings.playerSettings.items.pip.title',
description: 'settings.playerSettings.items.pip.description',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v112H314c-53 0-96 43-96 96v160H74c-26 0-48-21-48-48V74z" class="fill-primary/30"/><path d="M458 10c35 0 64 29 64 64v112h-16V74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h144v16H68c-31-3-55-27-58-57V74c0-33 25-60 58-64h390zm16 224c27 0 48 22 48 48v133c-3 24-23 43-48 43H309c-22-2-40-20-43-43V282c0-26 22-48 48-48h160zm-160 16c-18 0-32 14-32 32v128c0 18 14 32 32 32h160c18 0 32-14 32-32V282c0-18-14-32-32-32H314z" class="fill-primary"/></svg>`,
},
{
key: 'airplay' as const,
title: 'settings.playerSettings.items.airplay.title',
description: 'settings.playerSettings.items.airplay.description',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 436"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v224c0 26-21 47-47 48-45-45-91-91-136-137-32-31-82-31-114 0-45 46-91 91-136 137-26-1-47-22-47-48V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v224c0 26 21 47 47 48l-14 14c-28-7-49-32-49-62V74c0-35 29-64 64-64h384c35 0 64 29 64 64v224c0 30-21 55-49 62l-14-14c26-1 47-22 47-48V74c0-26-21-48-48-48zM138 410h256c7 0 12-4 15-10 2-6 1-13-4-17L277 255c-6-6-16-6-22 0L127 383c-5 4-6 11-4 17 3 6 9 10 15 10zm279-39c9 10 12 23 7 35s-17 20-30 20H138c-13 0-25-8-30-20s-2-25 7-35l128-128c13-12 33-12 46 0l128 128z" class="fill-primary"/></svg>`,
},
{
key: 'chromecast' as const,
title: 'settings.playerSettings.items.chromecast.title',
description: 'settings.playerSettings.items.chromecast.description',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v320c0 27-21 48-48 48H314v-9c0-154-125-279-279-279h-9V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v80H10V74c0-35 29-64 64-64h384c35 0 64 29 64 64v320c0 35-29 64-64 64H314v-16h144c27 0 48-21 48-48V74c0-26-21-48-48-48zM18 202c137 0 248 111 248 248 0 4-4 8-8 8s-8-4-8-8c0-128-104-232-232-232-4 0-8-4-8-8s4-8 8-8zm40 224c0-9-7-16-16-16s-16 7-16 16 7 16 16 16 16-7 16-16zm-48 0c0-18 14-32 32-32s32 14 32 32-14 32-32 32-32-14-32-32zm0-120c0-4 4-8 8-8 84 0 152 68 152 152 0 4-4 8-8 8s-8-4-8-8c0-75-61-136-136-136-4 0-8-4-8-8z" class="fill-primary"/></svg>`,
},
{
key: 'encrytion_m3u8' as const,
title: 'settings.playerSettings.items.encrytion_m3u8.title',
description: 'settings.playerSettings.items.encrytion_m3u8.description',
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="fill-primary/30" viewBox="0 0 564 564"><path d="M26 74c0-26 22-48 48-48h134c3 0 7 0 10 1v103c0 31 25 56 56 56h120v11c-38 18-64 56-64 101v29c-29 16-48 47-48 83v96H74c-26 0-48-21-48-48V74z"/><path d="M208 26H74c-26 0-48 22-48 48v384c0 27 22 48 48 48h208c0 6 1 11 1 16H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h134c17 0 33 7 45 19l122 122c10 10 16 22 18 35H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1zm156 137L241 40c-2-2-4-4-7-6v96c0 22 18 40 40 40h96c-2-3-4-5-6-7zm126 135c0-26-21-48-48-48-26 0-48 22-48 48v64h96v-64zM346 410v96c0 18 14 32 32 32h128c18 0 32-14 32-32v-96c0-18-14-32-32-32H378c-18 0-32 14-32 32zm160-112v64c27 0 48 22 48 48v96c0 27-21 48-48 48H378c-26 0-48-21-48-48v-96c0-26 22-48 48-48v-64c0-35 29-64 64-64s64 29 64 64z" class="fill-primary"/></svg>`,
},
]);
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
watch(preferencesSnapshot, (snapshot) => {
if (!snapshot) return;
playerSettings.value = createPlayerSettingsDraft(snapshot);
}, { immediate: true });
watch(error, (value, previous) => {
if (!value || value === previous || saving.value) return;
toast.add({
severity: 'error',
summary: t('settings.playerSettings.toast.failedSummary'),
detail: (value as any)?.message || t('settings.playerSettings.toast.failedDetail'),
life: 5000,
});
onMounted(() => {
router.replace({ name: 'settings-player-configs' });
});
const handleSave = async () => {
if (saving.value || !preferencesSnapshot.value) return;
saving.value = true;
try {
await rpcClient.updatePreferences(
toPlayerPreferencesPayload(playerSettings.value),
);
await refetch();
toast.add({
severity: 'success',
summary: t('settings.playerSettings.toast.savedSummary'),
detail: t('settings.playerSettings.toast.savedDetail'),
life: 3000,
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: t('settings.playerSettings.toast.failedSummary'),
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
life: 5000,
});
} finally {
saving.value = false;
}
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.player.title')"
:description="t('settings.content.player.subtitle')"
>
<template #header-actions>
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('common.save') }}
</AppButton>
</template>
<template v-if="isInitialLoading">
<SettingsRowSkeleton
v-for="item in settingsItems"
:key="item.key"
/>
</template>
<template v-else>
<SettingsRow
v-for="item in settingsItems"
:key="item.key"
:title="$t(item.title)"
:description="$t(item.description)"
iconBoxClass="bg-primary/10 text-primary"
>
<template #icon>
<span v-html="item.svg" class="h-6 w-6" />
</template>
<template #actions>
<AppSwitch v-model="playerSettings[item.key]" :disabled="isInteractionDisabled" />
</template>
</SettingsRow>
</template>
</SettingsSectionCard>
<div class="p-4 text-sm text-foreground/60">Redirecting...</div>
</template>

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { supportedLocales } from '@/i18n/constants';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, ref } from 'vue';
import SecurityAccountStatusRow from './components/SecurityAccountStatusRow.vue';
import SecurityChangePasswordDialog from './components/SecurityChangePasswordDialog.vue';
import SecurityChangePasswordRow from './components/SecurityChangePasswordRow.vue';
import SecurityEmailRow from './components/SecurityEmailRow.vue';
import SecurityLanguageRow from './components/SecurityLanguageRow.vue';
import SecurityLogoutRow from './components/SecurityLogoutRow.vue';
import SecurityTelegramRow from './components/SecurityTelegramRow.vue';
import SecurityTwoFactorDialog from './components/SecurityTwoFactorDialog.vue';
const auth = useAuthStore();
const toast = useAppToast();
@@ -191,275 +191,50 @@ const disconnectTelegram = async () => {
:title="t('settings.securityConnected.header.title')"
:description="t('settings.securityConnected.header.subtitle')"
>
<SettingsRow
:title="t('settings.securityConnected.accountStatus.label')"
:description="t('settings.securityConnected.accountStatus.detail')"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</template>
<SecurityAccountStatusRow />
<template #actions>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</template>
</SettingsRow>
<SecurityLanguageRow
:selected-language="selectedLanguage"
:language-options="languageOptions"
:language-saving="languageSaving"
@update:selected-language="selectedLanguage = $event"
@save="saveLanguage"
/>
<SettingsRow
:title="t('settings.securityConnected.language.label')"
:description="t('settings.securityConnected.language.detail')"
actionsClass="flex items-center gap-2"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 0 20" />
<path d="M12 2a15 15 0 0 0 0 20" />
</svg>
</template>
<SecurityChangePasswordRow @open="openChangePassword" />
<template #actions>
<select
v-model="selectedLanguage"
:disabled="languageSaving"
class="rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground disabled:opacity-60"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<AppButton
size="sm"
variant="secondary"
:loading="languageSaving"
:disabled="languageSaving"
@click="saveLanguage"
>
{{ t('settings.securityConnected.language.save') }}
</AppButton>
</template>
</SettingsRow>
<SecurityEmailRow :email-connected="emailConnected" />
<SettingsRow
:title="t('settings.securityConnected.changePassword.label')"
:description="t('settings.securityConnected.changePassword.detail')"
>
<template #icon>
<svg aria-hidden="true" class="fill-primary w-6 h-6" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</template>
<SecurityTelegramRow
:telegram-connected="telegramConnected"
:telegram-username="telegramUsername"
@connect="connectTelegram"
@disconnect="disconnectTelegram"
/>
<template #actions>
<AppButton variant="secondary" size="sm" @click="openChangePassword">
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.email.label')"
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="text-info w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</template>
<template #actions>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
</span>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.telegram.label')"
:description="telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected')"
>
<template #icon>
<TelegramIcon class="w-6 h-6 text-[#0088cc]" />
</template>
<template #actions>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="disconnectTelegram"
>
{{ t('settings.securityConnected.telegram.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
variant="secondary"
@click="connectTelegram"
>
{{ t('settings.securityConnected.telegram.connect') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.logout.label')"
:description="t('settings.securityConnected.logout.detail')"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<XCircleIcon class="w-6 h-6 text-danger" />
</template>
<template #actions>
<AppButton variant="danger" size="sm" @click="handleLogout">
<template #icon>
<XCircleIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.logout.button') }}
</AppButton>
</template>
</SettingsRow>
<SecurityLogoutRow @logout="handleLogout" />
</SettingsSectionCard>
<AppDialog
<SecurityTwoFactorDialog
:visible="twoFactorDialogVisible"
:two-factor-code="twoFactorCode"
:two-factor-secret="twoFactorSecret"
@update:visible="twoFactorDialogVisible = $event"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
@update:two-factor-code="twoFactorCode = $event"
@confirm="confirmTwoFactor"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
</AppDialog>
<AppDialog
<SecurityChangePasswordDialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ changePasswordError }}
</div>
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
v-model="currentPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
v-model="newPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
v-model="confirmPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="changePasswordLoading"
@click="changePasswordDialogVisible = false"
>
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
</AppButton>
<AppButton
size="sm"
:current-password="currentPassword"
:new-password="newPassword"
:confirm-password="confirmPassword"
:loading="changePasswordLoading"
@click="changePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>
</AppDialog>
:error="changePasswordError"
@update:visible="changePasswordDialogVisible = $event"
@update:current-password="currentPassword = $event"
@update:new-password="newPassword = $event"
@update:confirm-password="confirmPassword = $event"
@submit="changePassword"
/>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useTranslation } from 'i18next-vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
const { t } = useTranslation();
</script>
<template>
<SettingsRow
:title="t('settings.securityConnected.accountStatus.label')"
:description="t('settings.securityConnected.accountStatus.detail')"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
</template>
<template #actions>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</template>
</SettingsRow>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
visible: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
loading: boolean;
error: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'submit'): void;
}>();
const { t } = useTranslation();
const normalizeValue = (value: string | number | null) => typeof value === 'string' ? value : value == null ? '' : String(value);
</script>
<template>
<AppDialog
:visible="visible"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
@update:visible="emit('update:visible', $event)"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<div v-if="error" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ error }}
</div>
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
:model-value="currentPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
@update:model-value="emit('update:currentPassword', normalizeValue($event))"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
:model-value="newPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
@update:model-value="emit('update:newPassword', normalizeValue($event))"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
:model-value="confirmPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
@update:model-value="emit('update:confirmPassword', normalizeValue($event))"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="loading"
@click="emit('update:visible', false)"
>
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="loading"
@click="emit('submit')"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import { useTranslation } from 'i18next-vue';
const emit = defineEmits<{
(e: 'open'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<SettingsRow
:title="t('settings.securityConnected.changePassword.label')"
:description="t('settings.securityConnected.changePassword.detail')"
>
<template #icon>
<svg aria-hidden="true" class="fill-primary w-6 h-6" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</template>
<template #actions>
<AppButton variant="secondary" size="sm" @click="emit('open')">
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</template>
</SettingsRow>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
emailConnected: boolean;
}>();
const { t } = useTranslation();
</script>
<template>
<SettingsRow
:title="t('settings.securityConnected.email.label')"
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="text-info w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</template>
<template #actions>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
</span>
</template>
</SettingsRow>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
selectedLanguage: string;
languageOptions: Array<{ value: string; label: string }>;
languageSaving: boolean;
}>();
const emit = defineEmits<{
(e: 'update:selectedLanguage', value: string): void;
(e: 'save'): void;
}>();
const { t } = useTranslation();
const updateSelectedLanguage = (event: Event) => {
emit('update:selectedLanguage', (event.target as HTMLSelectElement).value);
};
</script>
<template>
<SettingsRow
:title="t('settings.securityConnected.language.label')"
:description="t('settings.securityConnected.language.detail')"
actionsClass="flex items-center gap-2"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 0 20" />
<path d="M12 2a15 15 0 0 0 0 20" />
</svg>
</template>
<template #actions>
<select
:value="selectedLanguage"
:disabled="languageSaving"
class="rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground disabled:opacity-60"
@change="updateSelectedLanguage"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<AppButton
size="sm"
variant="secondary"
:loading="languageSaving"
:disabled="languageSaving"
@click="emit('save')"
>
{{ t('settings.securityConnected.language.save') }}
</AppButton>
</template>
</SettingsRow>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import { useTranslation } from 'i18next-vue';
const emit = defineEmits<{
(e: 'logout'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<SettingsRow
:title="t('settings.securityConnected.logout.label')"
:description="t('settings.securityConnected.logout.detail')"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<XCircleIcon class="w-6 h-6 text-danger" />
</template>
<template #actions>
<AppButton variant="danger" size="sm" @click="emit('logout')">
<template #icon>
<XCircleIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.logout.button') }}
</AppButton>
</template>
</SettingsRow>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
telegramConnected: boolean;
telegramUsername: string;
}>();
const emit = defineEmits<{
(e: 'connect'): void;
(e: 'disconnect'): void;
}>();
const { t } = useTranslation();
</script>
<template>
<SettingsRow
:title="t('settings.securityConnected.telegram.label')"
:description="telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected')"
>
<template #icon>
<TelegramIcon class="w-6 h-6 text-[#0088cc]" />
</template>
<template #actions>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="emit('disconnect')"
>
{{ t('settings.securityConnected.telegram.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
variant="secondary"
@click="emit('connect')"
>
{{ t('settings.securityConnected.telegram.connect') }}
</AppButton>
</template>
</SettingsRow>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import CheckIcon from '@/components/icons/CheckIcon.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import { useTranslation } from 'i18next-vue';
defineProps<{
visible: boolean;
twoFactorCode: string;
twoFactorSecret: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:twoFactorCode', value: string): void;
(e: 'confirm'): void;
}>();
const { t } = useTranslation();
const updateCode = (value: string | number | null) => {
emit('update:twoFactorCode', typeof value === 'string' ? value : value == null ? '' : String(value));
};
</script>
<template>
<AppDialog
:visible="visible"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
@update:visible="emit('update:visible', $event)"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
:model-value="twoFactorCode"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
@update:model-value="updateCode"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="emit('update:visible', false)">
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
</AppButton>
<AppButton size="sm" @click="emit('confirm')">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -1,10 +1,8 @@
<template>
<section>
<PageHeader
:title="content[route.name as keyof typeof content]?.title || t('settings.content.fallbackTitle')"
<PageHeader :title="content[route.name as keyof typeof content]?.title || t('settings.content.fallbackTitle')"
:description="content[route.name as keyof typeof content]?.subtitle || t('settings.content.fallbackSubtitle')"
:breadcrumbs="breadcrumbs"
/>
:breadcrumbs="breadcrumbs" />
<div class="max-w-7xl mx-auto pb-12">
<div class="flex flex-col md:flex-row gap-8 mt-6">
@@ -15,29 +13,29 @@
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('app.name') }}</h3>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('app.name') }}
</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<nav class="space-y-6">
<div v-for="section in menuSections" :key="section.title">
<h3 v-if="section.title" class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
<h3 v-if="section.title"
class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
{{ section.title }}
</h3>
<ul class="space-y-0.5">
<li v-for="item in section.items" :key="item.value">
<router-link
:to="tabPaths[item.value]"
:class="[
<router-link :to="item.to" :class="[
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
currentTab === item.value
? 'bg-primary/10 text-primary font-semibold'
: item.danger
? 'text-danger hover:bg-danger/10'
: 'text-foreground/70 hover:bg-header hover:text-foreground'
]"
>
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" />
]">
<component :is="item.icon" class="w-5 h-5 shrink-0"
:filled="currentTab === item.value" />
{{ item.label }}
</router-link>
</li>
@@ -77,71 +75,100 @@ import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, createStaticVNode } from 'vue';
import { useRoute } from 'vue-router';
import { isAdmin } from '@/lib/utils';
const route = useRoute();
const auth = useAuthStore();
const { t } = useTranslation();
// Map tab values to their paths
const tabPaths: Record<string, string> = {
profile: '/settings',
security: '/settings/security',
notifications: '/settings/notifications',
player: '/settings/player',
billing: '/settings/billing',
domains: '/settings/domains',
ads: '/settings/ads',
danger: '/settings/danger',
};
type MenuItem = {
to: string
value: string
label: string
icon?: any
description?: string
danger?: boolean
}
// Menu items grouped by category (GitHub-style)
const menuSections = computed<{ title: string; items: { value: string; label: string; icon: any, danger?: boolean }[] }[]>(() => [
const menuSections = computed<{ title: string; items: MenuItem[] }[]>(() => [
{
title: t('settings.menu.securityGroup'),
items: [
{ value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(`<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -258 596 564"><path d="M144-120c0-44 36-80 80-80s80 36 80 80-36 80-80 80-80-36-80-80zm208 0c0-71-57-128-128-128S96-191 96-120 153 8 224 8s128-57 128-128zM48 232c0-71 57-128 128-128h64V77c0-7 1-14 3-21h-67C79 56 0 135 0 232v8c0 13 11 24 24 24s24-11 24-24v-8zm397 9-13 6V59l96 32v19c0 56-32 107-83 131zM422 12 310 49c-13 4-22 16-22 30v31c0 75 43 142 110 174l19 9c5 2 10 3 15 3s10-1 15-3l19-9c67-32 110-99 110-174V79c0-14-9-26-22-30L442 11c-6-2-14-2-20 0zm0 0z" fill="currentColor"/></svg>`, 1) },
{ value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
{
to: '/settings/security',
value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(`<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -258 596 564"><path d="M144-120c0-44 36-80 80-80s80 36 80 80-36 80-80 80-80-36-80-80zm208 0c0-71-57-128-128-128S96-191 96-120 153 8 224 8s128-57 128-128zM48 232c0-71 57-128 128-128h64V77c0-7 1-14 3-21h-67C79 56 0 135 0 232v8c0 13 11 24 24 24s24-11 24-24v-8zm397 9-13 6V59l96 32v19c0 56-32 107-83 131zM422 12 310 49c-13 4-22 16-22 30v31c0 75 43 142 110 174l19 9c5 2 10 3 15 3s10-1 15-3l19-9c67-32 110-99 110-174V79c0-14-9-26-22-30L442 11c-6-2-14-2-20 0zm0 0z" fill="currentColor"/></svg>`, 1)
},
{ to: '/settings/billing', value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
],
},
{
title: t('settings.menu.preferencesGroup'),
items: [
{ value: 'notifications', label: t('settings.menu.notifications'), icon: Bell },
{ value: 'player', label: t('settings.menu.player'), icon: VideoPlayIcon },
{ to: '/settings/notifications', value: 'notifications', label: t('settings.menu.notifications'), icon: Bell },
],
},
{
title: t('settings.menu.playerGroup'),
items: [
{ to: '/settings/player-configs', value: 'player-configs', label: t('settings.menu.playerConfigs'), icon: VideoPlayIcon },
],
},
{
title: t('settings.menu.integrationsGroup'),
items: [
{ value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon },
{ value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon },
{ to: '/settings/domains', value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon },
{ to: '/settings/ads', value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon },
],
},
...(isAdmin(auth.user?.role) ? [{
title: 'Admin Workspace',
items: [
{ to: '/settings/admin/users', value: 'admin-users', label: 'Users', description: 'Accounts, plans and moderation' },
{ to: '/settings/admin/videos', value: 'admin-videos', label: 'Videos', description: 'Cross-user media inventory' },
{ to: '/settings/admin/payments', value: 'admin-payments', label: 'Payments', description: 'Revenue, invoices and state changes' },
{ to: '/settings/admin/plans', value: 'admin-plans', label: 'Plans', description: 'Catalog and subscription offers' },
],
},
{
title: 'Admin Operations',
items: [
{ to: '/settings/admin/ad-templates', value: 'admin-ad-templates', label: 'Ad Templates', description: 'VAST templates and defaults' },
{ 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' },
{ to: '/settings/admin/logs', value: 'admin-logs', label: 'Logs', description: 'Direct runtime log lookup' },
],
},] : []),
{
title: t('settings.menu.dangerGroup'),
items: [
{ value: 'danger', label: t('settings.menu.danger'), icon: AlertTriangle, danger: true },
{ to: '/settings/danger', value: 'danger', label: t('settings.menu.danger'), icon: AlertTriangle, danger: true },
],
},
}
] as const);
type TabValue = 'profile' | 'security' | 'notifications' | 'player' | 'billing' | 'domains' | 'ads' | 'danger';
type TabValue = 'profile' | 'security' | 'notifications' | 'playerConfigs' | 'billing' | 'domains' | 'ads' | 'danger';
// Get current tab from route path
const currentTab = computed<TabValue>(() => {
const path = route.path as string;
const tabName = path.replace('/settings', '') || '/profile';
// support admin sub-routes
if (tabName.startsWith('/admin/')) {
return tabName.replace('/admin/', 'admin-') as TabValue;
}
if (tabName === '' || tabName === '/') return 'profile';
return (tabName.replace('/', '') as TabValue) || 'profile';
});
// Breadcrumbs with dynamic tab
const allMenuItems = computed(() => menuSections.value.flatMap(section => section.items));
const allMenuItems = computed(() => menuSections.value.map(section => section.items).flat());
const currentItem = computed(() => allMenuItems.value.find(item => item.value === currentTab.value));
const breadcrumbs = computed(() => [
{ label: t('pageHeader.dashboard'), to: '/overview' },
{ label: t('pageHeader.settings'), to: '/settings' },
...(currentItem.value ? [{ label: currentItem.value.label }] : []),
...(currentItem.value ? [{ label: currentItem.value.label + (currentItem.value.value.includes("admin") ? " (Admin)" : "") }] : []),
]);
const content = computed(() => ({
@@ -153,10 +180,6 @@ const content = computed(() => ({
title: t('settings.content.notifications.title'),
subtitle: t('settings.content.notifications.subtitle')
},
'settings-player': {
title: t('settings.content.player.title'),
subtitle: t('settings.content.player.subtitle')
},
'settings-billing': {
title: t('settings.content.billing.title'),
subtitle: t('settings.content.billing.subtitle')
@@ -169,9 +192,53 @@ const content = computed(() => ({
title: t('settings.content.ads.title'),
subtitle: t('settings.content.ads.subtitle')
},
'settings-player-configs': {
title: t('settings.content.playerConfigs.title'),
subtitle: t('settings.content.playerConfigs.subtitle')
},
'settings-danger': {
title: t('settings.content.danger.title'),
subtitle: t('settings.content.danger.subtitle')
}
},
'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-player-configs': {
title: 'Player Configs',
subtitle: 'Cross-user player presets, flags 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>

View File

@@ -2,11 +2,14 @@
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminSelect from "./components/AdminSelect.vue";
import AdminTextarea from "./components/AdminTextarea.vue";
import AdminTable from "./components/AdminTable.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -310,24 +313,25 @@ const columns = computed<ColumnDef<AdminAdTemplateRow>[]>(() => [
},
]);
// 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;
// },
// },
// ],
// }));
useAdminPageHeader(() => ({
eyebrow: 'Advertising',
badge: loading.value ? 'Syncing template inventory' : `${total.value} total templates`,
actions: [
{
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadTemplates,
},
{
label: 'Create template',
onClick: () => {
actionError.value = null;
createOpen.value = true;
},
},
],
}));
onMounted(loadTemplates);
</script>
@@ -336,40 +340,38 @@ onMounted(loadTemplates);
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<SettingsSectionCard title="Filters" description="Search templates by name and narrow by owner reference if needed." bodyClass="p-5">
<AdminSectionCard 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" />
<label class="text-xs font-medium text-foreground/60">Search</label>
<AdminInput 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" />
<label class="text-xs font-medium text-foreground/60">Owner reference</label>
<AdminInput 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>
</SettingsSectionCard>
</AdminSectionCard>
<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>
<SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadTemplates">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true; actionError = null">Create template</AppButton>
</template>
<AdminSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
<BaseTable
<AdminTable
v-else
:data="rows"
:columns="columns"
@@ -385,16 +387,16 @@ onMounted(loadTemplates);
<p class="text-xs text-foreground/40">Try a broader template name or clear the owner filter.</p>
</div>
</template>
</BaseTable>
</AdminTable>
<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="text-xs text-foreground/55">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>
</SettingsSectionCard>
</AdminSectionCard>
</div>
</AdminSectionShell>
@@ -406,12 +408,12 @@ onMounted(loadTemplates);
</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="text-[11px] font-medium text-foreground/55">{{ 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="text-[11px] font-medium text-foreground/55">VAST URL</div>
<div class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.vastTagUrl }}</div>
</div>
</div>
@@ -429,36 +431,36 @@ onMounted(loadTemplates);
<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">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" />
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AdminInput 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">Name</label>
<AppInput v-model="createForm.name" placeholder="Preroll template" />
<label class="text-sm font-medium text-foreground/70">Name</label>
<AdminInput v-model="createForm.name" placeholder="Preroll template" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="createForm.description" rows="3" 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" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">VAST URL</label>
<AppInput v-model="createForm.vastTagUrl" placeholder="https://..." />
<label class="text-sm font-medium text-foreground/70">VAST URL</label>
<AdminInput v-model="createForm.vastTagUrl" placeholder="https://..." />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad format</label>
<select v-model="createForm.adFormat" 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">
<label class="text-sm font-medium text-foreground/70">Ad format</label>
<AdminSelect v-model="createForm.adFormat">
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Duration</label>
<AdminInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" />
Default
</label>
@@ -477,36 +479,36 @@ onMounted(loadTemplates);
<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">Owner user ID</label>
<AppInput v-model="editForm.userId" />
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AdminInput v-model="editForm.userId" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label>
<AppInput v-model="editForm.name" />
<label class="text-sm font-medium text-foreground/70">Name</label>
<AdminInput v-model="editForm.name" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="editForm.description" rows="3" 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" />
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="editForm.description" rows="3" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">VAST URL</label>
<AppInput v-model="editForm.vastTagUrl" />
<label class="text-sm font-medium text-foreground/70">VAST URL</label>
<AdminInput v-model="editForm.vastTagUrl" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad format</label>
<select v-model="editForm.adFormat" 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">
<label class="text-sm font-medium text-foreground/70">Ad format</label>
<AdminSelect v-model="editForm.adFormat">
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Duration</label>
<AdminInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" />
Default
</label>
@@ -523,7 +525,7 @@ onMounted(loadTemplates);
<AppDialog v-model:visible="deleteOpen" title="Delete ad template" 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">
<p class="text-sm text-foreground/70">
Delete ad template <span class="font-medium">{{ selectedRow?.name || 'this template' }}</span>.
</p>
</div>

View File

@@ -2,13 +2,15 @@
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import AdminTable from "./components/AdminTable.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import type { ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.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];
@@ -164,7 +166,7 @@ const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
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)],
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", 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",
@@ -221,6 +223,19 @@ const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
},
]);
useAdminPageHeader(() => ({
eyebrow: 'Workers',
badge: loading.value ? 'Syncing agent fleet' : `${rows.value.length} agents tracked`,
actions: [
{
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadAgents,
},
],
}));
useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic !== "picpic/events") return;
@@ -265,22 +280,21 @@ onMounted(loadAgents);
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<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>
<SettingsSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadAgents">Refresh</AppButton>
</template>
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
<AdminSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="8" :rows="4" />
<BaseTable
<AdminTable
v-else
:data="rows"
:columns="columns"
@@ -296,8 +310,8 @@ onMounted(loadAgents);
<p class="text-xs text-foreground/40">Workers will appear here when they register with the admin runtime.</p>
</div>
</template>
</BaseTable>
</SettingsSectionCard>
</AdminTable>
</AdminSectionCard>
</div>
</AdminSectionShell>
@@ -310,18 +324,18 @@ onMounted(loadAgents);
<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="text-[11px] font-medium text-foreground/55">{{ 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="text-[11px] font-medium text-foreground/55">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="text-[11px] font-medium text-foreground/55">RAM</div>
<div class="mt-1 text-sm font-medium text-foreground">{{ formatRam(selectedRow.ram) }}</div>
</div>
</div>
@@ -338,7 +352,7 @@ onMounted(loadAgents);
<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">
<p class="text-sm text-foreground/70">
Send restart command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
</p>
</div>
@@ -353,7 +367,7 @@ onMounted(loadAgents);
<AppDialog v-model:visible="updateOpen" title="Update 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">
<p class="text-sm text-foreground/70">
Send update command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
</p>
</div>

View File

@@ -2,12 +2,14 @@
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminTextarea from "./components/AdminTextarea.vue";
import AdminTable from "./components/AdminTable.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -16,10 +18,13 @@ type ListJobsResponse = Awaited<ReturnType<typeof rpcClient.listAdminJobs>>;
type AdminJobRow = NonNullable<ListJobsResponse["jobs"]>[number];
const loading = ref(true);
const loadingMore = ref(false);
const submitting = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const rows = ref<AdminJobRow[]>([]);
const nextCursor = ref<string | undefined>(undefined);
const hasMore = ref(false);
const selectedRow = ref<AdminJobRow | null>(null);
const selectedLogs = ref("");
const activeAgentFilter = ref("");
@@ -122,11 +127,13 @@ const loadJobs = async () => {
error.value = null;
try {
const response = await rpcClient.listAdminJobs({
offset: 0,
limit: 50,
cursor: undefined,
pageSize: 50,
agentId: appliedAgentFilter.value.trim() || undefined,
});
rows.value = response.jobs ?? [];
nextCursor.value = response.nextCursor || undefined;
hasMore.value = Boolean(response.hasMore);
syncSelectedRow();
} catch (err: any) {
error.value = err?.message || "Failed to load admin jobs";
@@ -135,8 +142,31 @@ const loadJobs = async () => {
}
};
const loadMoreJobs = async () => {
if (loading.value || loadingMore.value || !hasMore.value || !nextCursor.value) return;
loadingMore.value = true;
actionError.value = null;
try {
const response = await rpcClient.listAdminJobs({
cursor: nextCursor.value,
pageSize: 50,
agentId: appliedAgentFilter.value.trim() || undefined,
});
rows.value = [...rows.value, ...(response.jobs ?? [])];
nextCursor.value = response.nextCursor || undefined;
hasMore.value = Boolean(response.hasMore);
syncSelectedRow();
} catch (err: any) {
actionError.value = err?.message || "Failed to load more jobs";
} finally {
loadingMore.value = false;
}
};
const applyFilters = async () => {
appliedAgentFilter.value = activeAgentFilter.value;
nextCursor.value = undefined;
hasMore.value = false;
await loadJobs();
};
@@ -272,7 +302,7 @@ const columns = computed<ColumnDef<AdminJobRow>[]>(() => [
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)],
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", 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",
@@ -380,16 +410,17 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
});
useAdminPageHeader(() => ({
eyebrow: "Runtime",
badge: `${rows.value.length} jobs loaded`,
eyebrow: 'Runtime',
badge: loading.value ? 'Polling queue state' : `${rows.value.length} jobs loaded`,
actions: [
{
label: "Refresh",
variant: "secondary",
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadJobs,
},
{
label: "Create job",
label: 'Create job',
onClick: () => {
actionError.value = null;
createOpen.value = true;
@@ -405,37 +436,39 @@ onMounted(loadJobs);
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<SettingsSectionCard title="Filters" description="Find jobs by name or status, then narrow the list by assigned agent if needed." bodyClass="p-5">
<AdminSectionCard 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" />
<label class="text-xs font-medium text-foreground/60">Assigned agent</label>
<AdminInput 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" />
<label class="text-xs font-medium text-foreground/60">Search</label>
<AdminInput 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="ghost" @click="activeAgentFilter = ''; appliedAgentFilter = ''; search = ''; nextCursor = undefined; hasMore = false; loadJobs()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div>
</div>
</SettingsSectionCard>
</AdminSectionCard>
<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>
<SettingsSectionCard v-else title="Jobs" description="Current queue state and operator actions." bodyClass="">
<AdminSectionCard v-else title="Jobs" description="Current queue state and operator actions." bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable
v-else
<div v-else>
<AdminTable
:data="filteredRows"
:columns="columns"
:get-row-id="(row) => row.id || row.name || ''"
@@ -450,8 +483,14 @@ onMounted(loadJobs);
<p class="text-xs text-foreground/40">Try a broader job name or clear the agent filter.</p>
</div>
</template>
</BaseTable>
</SettingsSectionCard>
</AdminTable>
<div class="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
<p class="text-xs text-foreground/50">{{ hasMore ? 'More jobs available.' : 'Showing the latest jobs.' }}</p>
<AppButton v-if="hasMore" size="sm" variant="secondary" :loading="loadingMore" @click="loadMoreJobs">Load more</AppButton>
</div>
</div>
</AdminSectionCard>
</div>
</AdminSectionShell>
@@ -464,13 +503,13 @@ onMounted(loadJobs);
<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="text-[11px] font-medium text-foreground/55">{{ 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">
<div class="flex items-center justify-between gap-2 text-[11px] 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>
@@ -491,32 +530,32 @@ onMounted(loadJobs);
<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">Command</label>
<textarea v-model="createForm.command" rows="4" 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" placeholder="ffmpeg -i ..." />
<label class="text-sm font-medium text-foreground/70">Command</label>
<AdminTextarea v-model="createForm.command" rows="4" placeholder="ffmpeg -i ..." />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Image</label>
<AppInput v-model="createForm.image" placeholder="alpine" />
<label class="text-sm font-medium text-foreground/70">Image</label>
<AdminInput v-model="createForm.image" placeholder="alpine" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AdminInput v-model="createForm.userId" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Display name</label>
<AppInput v-model="createForm.name" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Display name</label>
<AdminInput v-model="createForm.name" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Priority</label>
<AppInput v-model="createForm.priority" type="number" />
<label class="text-sm font-medium text-foreground/70">Priority</label>
<AdminInput v-model="createForm.priority" type="number" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Time limit</label>
<AppInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
<label class="text-sm font-medium text-foreground/70">Time limit</label>
<AdminInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Environment</label>
<textarea v-model="createForm.envText" rows="5" 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" placeholder="KEY=value per line" />
<label class="text-sm font-medium text-foreground/70">Environment</label>
<AdminTextarea v-model="createForm.envText" rows="5" placeholder="KEY=value per line" />
</div>
</div>
</div>
@@ -545,7 +584,7 @@ onMounted(loadJobs);
<AppDialog v-model:visible="cancelOpen" title="Cancel job" 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">
<p class="text-sm text-foreground/70">
Cancel <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
</p>
</div>
@@ -560,7 +599,7 @@ onMounted(loadJobs);
<AppDialog v-model:visible="retryOpen" title="Retry job" 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">
<p class="text-sm text-foreground/70">
Retry <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
</p>
</div>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, provide } from 'vue';
import { useRoute } from 'vue-router';
import PageHeader from "@/components/dashboard/PageHeader.vue"
import { adminPageHeaderKey, createAdminPageHeaderState } from './components/useAdminPageHeader';
const route = useRoute();
const pageHeader = createAdminPageHeaderState();
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/player-configs', label: 'Player Configs', description: 'Cross-user player presets 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 matchesItem = (to: string) => route.path === to || route.path.startsWith(`${to}/`);
const activeSection = computed(() => {
const allSections = menuSections.map((section) => section.items).flat();
return allSections.find((section) => matchesItem(section.to)) ?? allSections[0];
});
const activeMenuGroup = computed(() => {
return menuSections.find((section) => section.items.some((item) => matchesItem(item.to))) ?? menuSections[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-player-configs': {
title: 'Player Configs',
subtitle: 'Cross-user player presets, flags 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="space-y-3">
<PageHeader
:title="content[route.name as keyof typeof content]?.title || 'Workspace administration'"
:description="content[route.name as keyof typeof content]?.subtitle || 'settings.content.fallbackSubtitle'"
:breadcrumbs="breadcrumbs"
/>
</div>
<div class="mx-auto max-w-[1440px] pb-10">
<div class="grid gap-6 xl:grid-cols-[232px_minmax(0,1fr)] xl:items-start">
<aside class="md:w-56 shrink-0">
<nav class="space-y-6">
<div v-for="section in menuSections" :key="section.title">
<h3 v-if="section.title" class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
{{ 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="[
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
matchesItem(item.to)
? 'bg-primary/10 text-primary font-semibold'
: 'text-foreground/70 hover:bg-header hover:text-foreground'
]"
>
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" />
{{ item.label }}
</router-link>
</li>
</ul>
</div>
</nav>
</aside>
<main class="min-w-0">
<router-view />
</main>
</div>
</div>
</section>
</template>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AdminInput from "./components/AdminInput.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { computed, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
const loading = ref(false);
const error = ref<string | null>(null);
@@ -23,6 +25,20 @@ const summary = computed(() => [
const activeChannel = computed(() => activeJobId.value ? `picpic/logs/${activeJobId.value}` : "No active stream");
useAdminPageHeader(() => ({
eyebrow: 'Diagnostics',
badge: activeJobId.value ? `Streaming ${activeJobId.value}` : 'Awaiting job selection',
actions: [
{
label: 'Fetch logs',
variant: 'secondary',
loading: loading.value,
disabled: !jobId.trim(),
onClick: loadLogs,
},
],
}));
const loadLogs = async () => {
if (!jobId.value.trim()) return;
loading.value = true;
@@ -65,18 +81,20 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<SettingsSectionCard title="Log session" description="Load persisted logs once, then keep appending live lines for the same job." bodyClass="p-5">
<AdminSectionCard 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" />
<label class="text-xs font-medium text-foreground/60">Job ID</label>
<AdminInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="ghost" @click="clearLogs">Reset</AppButton>
@@ -86,24 +104,30 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
<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="text-[11px] font-medium text-foreground/55">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>
</AdminSectionCard>
<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>
<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>
<AdminSectionCard title="Runtime output" :description="activeJobId || 'idle'" bodyClass="p-5">
<div class="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950">
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-4 py-3">
<div class="text-[11px] font-medium text-slate-400">Live stream buffer</div>
<div class="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-300">
{{ activeJobId ? 'Connected' : 'Idle' }}
</div>
</SettingsSectionCard>
</div>
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words px-4 py-4 font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre>
</div>
</AdminSectionCard>
</div>
</AdminSectionShell>
</template>

View File

@@ -2,13 +2,15 @@
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminSelect from "./components/AdminSelect.vue";
import AdminTable from "./components/AdminTable.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import BillingPlansSection from "@/routes/settings/Billing/components/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 AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -241,24 +243,25 @@ const statusBadgeClass = (status?: string) => {
}
};
// 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;
// },
// },
// ],
// }));
useAdminPageHeader(() => ({
eyebrow: 'Finance',
badge: loading.value ? 'Syncing payment records' : `${total.value} payment records`,
actions: [
{
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadPayments,
},
{
label: 'Create payment',
onClick: () => {
actionError.value = null;
createOpen.value = true;
},
},
],
}));
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
{
@@ -309,7 +312,7 @@ const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
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)],
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", 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",
@@ -355,44 +358,42 @@ onMounted(() => {
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<SettingsSectionCard title="Filters" description="Filter payments by user reference and status." bodyClass="p-5">
<AdminSectionCard 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" />
<label class="text-xs font-medium text-foreground/60">User reference</label>
<AdminInput 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">
<label class="text-xs font-medium text-foreground/60">Status</label>
<AdminSelect v-model="statusFilter">
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
</select>
</AdminSelect>
</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>
</AdminSectionCard>
<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>
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadPayments">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true">Create payment</AppButton>
</template>
<AdminSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable
<AdminTable
v-else
:data="rows"
:columns="columns"
@@ -408,16 +409,16 @@ onMounted(() => {
<p class="text-xs text-foreground/40">Try a broader user reference or clear the status filter.</p>
</div>
</template>
</BaseTable>
</AdminTable>
<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="text-xs text-foreground/55">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>
</SettingsSectionCard>
</AdminSectionCard>
</div>
</AdminSectionShell>
@@ -430,7 +431,7 @@ onMounted(() => {
<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="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div>
</div>
@@ -449,22 +450,22 @@ onMounted(() => {
<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" />
<label class="text-sm font-medium text-foreground/70">User ID</label>
<AdminInput v-model="createForm.userId" placeholder="user-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" />
<label class="text-sm font-medium text-foreground/70">Term months</label>
<AdminInput v-model="createForm.termMonths" type="number" min="1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Payment method</label>
<select v-model="createForm.paymentMethod" 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">
<label class="text-sm font-medium text-foreground/70">Payment method</label>
<AdminSelect v-model="createForm.paymentMethod">
<option v-for="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Topup amount</label>
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Topup amount</label>
<AdminInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
</div>
</div>
@@ -499,10 +500,10 @@ onMounted(() => {
<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="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label>
<select v-model="statusForm.status" 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">
<label class="text-sm font-medium text-foreground/70">Status</label>
<AdminSelect v-model="statusForm.status">
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
</select>
</AdminSelect>
</div>
</div>
<template #footer>

View File

@@ -2,11 +2,15 @@
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminSelect from "./components/AdminSelect.vue";
import AdminTextarea from "./components/AdminTextarea.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { computed, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
import { formatBytes } from "@/lib/utils";
type ListPlansResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlans>>;
type AdminPlanRow = NonNullable<ListPlansResponse["plans"]>[number];
@@ -59,7 +63,7 @@ const summary = computed(() => [
{ label: "Plans", value: rows.value.length },
{ label: "Active", value: rows.value.filter((row) => row.isActive).length },
{ label: "Highest price", value: rows.value.reduce((max, row) => Math.max(max, Number(row.price ?? 0)), 0) },
{ label: "Avg storage", value: Math.round(rows.value.reduce((sum, row) => sum + Number(row.storageLimit ?? 0), 0) / Math.max(rows.value.length, 1)) },
{ label: "Avg storage", value: formatBytes(Math.round(rows.value.reduce((sum, row) => sum + Number(row.storageLimit ?? 0), 0) / Math.max(rows.value.length, 1))) },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
@@ -208,6 +212,7 @@ useAdminPageHeader(() => ({
{
label: "Refresh",
variant: "secondary",
loading: loading.value,
onClick: loadPlans,
},
{
@@ -227,64 +232,70 @@ onMounted(loadPlans);
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<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">
<SettingsSectionCard
v-for="row in rows"
:key="row.id"
:title="row.name"
:description="row.description || 'No description'"
bodyClass="p-5"
>
<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 class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3" v-else>
<div v-for="row in rows" :key="row.id" class="flex flex-col max-w-sm w-full bg-white rounded-2xl border border-gray-200 overflow-hidden transition-all hover:shadow-xl">
<div class="p-6 border-b border-gray-100">
<div class="flex justify-between items-start">
<div>
<h3 class="text-xl font-bold text-gray-900">{{ row.name }}</h3>
<p class="text-sm text-gray-500">{{ row.description || 'No description' }}</p>
</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>
<span v-if="row.isActive" class="px-3 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full">Active</span>
<span v-else class="px-3 py-1 text-xs font-semibold text-red-700 bg-red-100 rounded-full">Inactive</span>
</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 class="mt-4">
<span class="text-3xl font-extrabold text-gray-900">${{ row.price }}</span>
<span class="text-gray-500 text-sm">/{{ row.cycle }}</span>
</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>
<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">
<div class="p-6 bg-gray-50/50 space-y-4">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="Check-circle" />
</svg>
<span class="text-sm text-gray-700">Storage: {{ formatBytes(row.storageLimit) }}</span>
</div>
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="Check-circle" />
</svg>
<span class="text-sm text-gray-700">Uploads: {{ row.uploadLimit }}</span>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="text-xs font-medium text-gray-500">Features</div>
<ul class="mt-2 space-y-1 text-sm text-gray-700">
<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>
<li v-if="!(row.features || []).length" class="text-gray-500">No features listed.</li>
</ul>
</div>
</div>
<div class="flex justify-end gap-2">
<div class="p-4 bg-white flex gap-2 mt-a">
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">
Edit Plan
</AppButton>
<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>
<AppButton size="sm" variant="secondary" @click="openMenuDialog(row)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="Circle-dots-horizontal" />
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</AppButton>
</div>
</div>
</SettingsSectionCard>
</div>
</div>
</AdminSectionShell>
@@ -297,12 +308,12 @@ onMounted(loadPlans);
</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="text-[11px] font-medium text-foreground/55">{{ 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>
<div class="text-[11px] font-medium text-foreground/55">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>
@@ -323,36 +334,36 @@ onMounted(loadPlans);
<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">Name</label>
<AppInput v-model="createForm.name" placeholder="Starter" />
<label class="text-sm font-medium text-foreground/70">Name</label>
<AdminInput v-model="createForm.name" placeholder="Starter" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="createForm.description" rows="3" 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" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="createForm.description" :rows="3" placeholder="Optional" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Features</label>
<textarea v-model="createForm.featuresText" rows="4" 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" placeholder="One feature per line" />
<label class="text-sm font-medium text-foreground/70">Features</label>
<AdminTextarea v-model="createForm.featuresText" :rows="4" placeholder="One feature per line" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Price</label>
<AppInput v-model="createForm.price" type="number" min="0" step="0.01" />
<label class="text-sm font-medium text-foreground/70">Price</label>
<AdminInput v-model="createForm.price" type="number" min="0" step="0.01" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Cycle</label>
<select v-model="createForm.cycle" 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">
<label class="text-sm font-medium text-foreground/70">Cycle</label>
<AdminSelect v-model="createForm.cycle">
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Storage limit</label>
<AppInput v-model="createForm.storageLimit" type="number" min="1" />
<label class="text-sm font-medium text-foreground/70">Storage limit</label>
<AdminInput v-model="createForm.storageLimit" type="number" min="1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Upload limit</label>
<AppInput v-model="createForm.uploadLimit" type="number" min="1" />
<label class="text-sm font-medium text-foreground/70">Upload limit</label>
<AdminInput v-model="createForm.uploadLimit" type="number" min="1" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
<label class="flex items-center gap-2 text-sm text-foreground/70 md:col-span-2">
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
@@ -371,36 +382,36 @@ onMounted(loadPlans);
<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">Name</label>
<AppInput v-model="editForm.name" />
<label class="text-sm font-medium text-foreground/70">Name</label>
<AdminInput v-model="editForm.name" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="editForm.description" rows="3" 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" />
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="editForm.description" :rows="3" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Features</label>
<textarea v-model="editForm.featuresText" rows="4" 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" />
<label class="text-sm font-medium text-foreground/70">Features</label>
<AdminTextarea v-model="editForm.featuresText" :rows="4" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Price</label>
<AppInput v-model="editForm.price" type="number" min="0" step="0.01" />
<label class="text-sm font-medium text-foreground/70">Price</label>
<AdminInput v-model="editForm.price" type="number" min="0" step="0.01" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Cycle</label>
<select v-model="editForm.cycle" 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">
<label class="text-sm font-medium text-foreground/70">Cycle</label>
<AdminSelect v-model="editForm.cycle">
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Storage limit</label>
<AppInput v-model="editForm.storageLimit" type="number" min="1" />
<label class="text-sm font-medium text-foreground/70">Storage limit</label>
<AdminInput v-model="editForm.storageLimit" type="number" min="1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Upload limit</label>
<AppInput v-model="editForm.uploadLimit" type="number" min="1" />
<label class="text-sm font-medium text-foreground/70">Upload limit</label>
<AdminInput v-model="editForm.uploadLimit" type="number" min="1" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
<label class="flex items-center gap-2 text-sm text-foreground/70 md:col-span-2">
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
@@ -417,7 +428,7 @@ onMounted(loadPlans);
<AppDialog v-model:visible="deleteOpen" title="Delete plan" 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">
<p class="text-sm text-foreground/70">
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || 'this plan' }}</span>.
</p>
</div>

View File

@@ -0,0 +1,566 @@
<script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminTextarea from "./components/AdminTextarea.vue";
import AdminTable from "./components/AdminTable.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
type ListConfigsResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlayerConfigs>>;
type AdminPlayerConfigRow = NonNullable<ListConfigsResponse["configs"]>[number];
const loading = ref(true);
const submitting = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const rows = ref<AdminPlayerConfigRow[]>([]);
const total = ref(0);
const limit = ref(12);
const page = ref(1);
const selectedRow = ref<AdminPlayerConfigRow | null>(null);
const search = ref("");
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);
const createForm = reactive({
userId: "",
name: "",
description: "",
autoplay: false,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
chromecast: true,
encrytionM3u8: true,
logoUrl: "",
isActive: true,
isDefault: false,
});
const editForm = reactive({
id: "",
userId: "",
name: "",
description: "",
autoplay: false,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
chromecast: true,
encrytionM3u8: true,
logoUrl: "",
isActive: true,
isDefault: false,
});
const canCreate = computed(() => createForm.userId.trim() && createForm.name.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.name.trim());
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const summary = computed(() => [
{ label: "Visible configs", value: rows.value.length },
{ label: "Active", value: rows.value.filter((row) => row.isActive).length },
{ label: "Default", value: rows.value.filter((row) => row.isDefault).length },
{ label: "Total records", value: total.value },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Owner", value: selectedRow.value.ownerEmail || selectedRow.value.userId || "—" },
{ label: "Status", value: selectedRow.value.isActive ? "ACTIVE" : "INACTIVE" },
{ label: "Default", value: selectedRow.value.isDefault ? "YES" : "NO" },
{ label: "Encrypted HLS", value: selectedRow.value.encrytionM3u8 ? "ENABLED" : "DISABLED" },
{ label: "Logo URL", value: selectedRow.value.logoUrl || "—" },
{ label: "Created", value: formatDate(selectedRow.value.createdAt) },
{ label: "Updated", value: formatDate(selectedRow.value.updatedAt) },
];
});
const configFlags = (row: AdminPlayerConfigRow) => {
const flags: string[] = [];
if (row.autoplay) flags.push("Autoplay");
if (row.loop) flags.push("Loop");
if (row.muted) flags.push("Muted");
if (row.showControls) flags.push("Controls");
if (row.pip) flags.push("PiP");
if (row.airplay) flags.push("AirPlay");
if (row.chromecast) flags.push("Chromecast");
if (row.encrytionM3u8) flags.push("Encrypted HLS");
if (row.logoUrl) flags.push("Logo");
return flags;
};
const loadConfigs = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminPlayerConfigs({
page: page.value,
limit: limit.value,
userId: appliedOwnerFilter.value.trim() || undefined,
search: appliedSearch.value.trim() || undefined,
});
rows.value = response.configs ?? [];
total.value = response.total ?? rows.value.length;
limit.value = response.limit ?? limit.value;
page.value = response.page ?? page.value;
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;
}
} catch (err: any) {
error.value = err?.message || "Failed to load admin player configs";
} finally {
loading.value = false;
}
};
const resetCreateForm = () => {
createForm.userId = "";
createForm.name = "";
createForm.description = "";
createForm.autoplay = false;
createForm.loop = false;
createForm.muted = false;
createForm.showControls = true;
createForm.pip = true;
createForm.airplay = true;
createForm.chromecast = true;
createForm.encrytionM3u8 = true;
createForm.logoUrl = "";
createForm.isActive = true;
createForm.isDefault = false;
};
const closeDialogs = () => {
createOpen.value = false;
detailOpen.value = false;
editOpen.value = false;
deleteOpen.value = false;
actionError.value = null;
};
const applyFilters = async () => {
page.value = 1;
appliedSearch.value = search.value;
appliedOwnerFilter.value = ownerFilter.value;
await loadConfigs();
};
const openDetailDialog = (row: AdminPlayerConfigRow) => {
selectedRow.value = row;
actionError.value = null;
detailOpen.value = true;
};
const openEditDialog = (row: AdminPlayerConfigRow) => {
selectedRow.value = row;
actionError.value = null;
editForm.id = row.id || "";
editForm.userId = row.userId || "";
editForm.name = row.name || "";
editForm.description = row.description || "";
editForm.autoplay = !!row.autoplay;
editForm.loop = !!row.loop;
editForm.muted = !!row.muted;
editForm.showControls = !!row.showControls;
editForm.pip = !!row.pip;
editForm.airplay = !!row.airplay;
editForm.chromecast = !!row.chromecast;
editForm.encrytionM3u8 = row.encrytionM3u8 !== false;
editForm.logoUrl = row.logoUrl || "";
editForm.isActive = !!row.isActive;
editForm.isDefault = !!row.isDefault;
editOpen.value = true;
};
const openDeleteDialog = (row: AdminPlayerConfigRow) => {
selectedRow.value = row;
actionError.value = null;
deleteOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminPlayerConfig({
userId: createForm.userId.trim(),
name: createForm.name.trim(),
description: createForm.description.trim() || undefined,
autoplay: createForm.autoplay,
loop: createForm.loop,
muted: createForm.muted,
showControls: createForm.showControls,
pip: createForm.pip,
airplay: createForm.airplay,
chromecast: createForm.chromecast,
encrytionM3u8: createForm.encrytionM3u8,
logoUrl: createForm.logoUrl.trim() || undefined,
isActive: createForm.isActive,
isDefault: createForm.isDefault,
});
resetCreateForm();
createOpen.value = false;
await loadConfigs();
} catch (err: any) {
actionError.value = err?.message || "Failed to create player config";
} finally {
submitting.value = false;
}
};
const submitEdit = async () => {
if (!canUpdate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminPlayerConfig({
id: editForm.id,
userId: editForm.userId.trim(),
name: editForm.name.trim(),
description: editForm.description.trim() || undefined,
autoplay: editForm.autoplay,
loop: editForm.loop,
muted: editForm.muted,
showControls: editForm.showControls,
pip: editForm.pip,
airplay: editForm.airplay,
chromecast: editForm.chromecast,
encrytionM3u8: editForm.encrytionM3u8,
logoUrl: editForm.logoUrl.trim() || undefined,
isActive: editForm.isActive,
isDefault: editForm.isDefault,
});
editOpen.value = false;
await loadConfigs();
} catch (err: any) {
actionError.value = err?.message || "Failed to update player config";
} finally {
submitting.value = false;
}
};
const submitDelete = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.deleteAdminPlayerConfig({ id: selectedRow.value.id });
deleteOpen.value = false;
selectedRow.value = null;
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
await loadConfigs();
} catch (err: any) {
actionError.value = err?.message || "Failed to delete player config";
} finally {
submitting.value = false;
}
};
const previousPage = async () => {
if (page.value <= 1) return;
page.value -= 1;
await loadConfigs();
};
const nextPage = async () => {
if (page.value >= totalPages.value) return;
page.value += 1;
await loadConfigs();
};
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
useAdminPageHeader(() => ({
eyebrow: 'Playback',
badge: loading.value ? 'Syncing config presets' : `${total.value} configs loaded`,
actions: [
{
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadConfigs,
},
{
label: 'Create config',
onClick: () => {
actionError.value = null;
createOpen.value = true;
},
},
],
}));
const columns = computed<ColumnDef<AdminPlayerConfigRow>[]>(() => [
{
id: "config",
header: "Config",
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: "flags",
header: "Flags",
accessorFn: row => configFlags(row).join(", "),
cell: ({ row }) => h("div", { class: "flex flex-wrap gap-1" },
configFlags(row.original).length
? configFlags(row.original).map((flag) => h("span", { class: "rounded bg-primary/10 px-2 py-0.5 text-xs text-primary" }, flag))
: [h("span", { class: "text-foreground/50" }, "—")]
),
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",
},
},
]);
onMounted(loadConfigs);
</script>
<template>
<AdminSectionShell>
<template #stats>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<AdminSectionCard title="Filters" description="Search configs 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-medium text-foreground/60">Search</label>
<AdminInput v-model="search" placeholder="Search config name" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-foreground/60">Owner reference</label>
<AdminInput 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 = ''; loadConfigs()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div>
</div>
</AdminSectionCard>
<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>
<AdminSectionCard v-else title="Player configs" description="Cross-user player presets and default assignments." bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="5" :rows="4" />
<AdminTable
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 player configs matched the current filters.</p>
<p class="text-xs text-foreground/40">Try a broader config name or clear the owner filter.</p>
</div>
</template>
</AdminTable>
<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 text-foreground/55">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>
</AdminSectionCard>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="detailOpen" title="Player config 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] font-medium text-foreground/55">{{ 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] font-medium text-foreground/55">Flags</div>
<div class="mt-2 flex flex-wrap gap-2">
<span v-for="flag in configFlags(selectedRow)" :key="flag" class="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">{{ flag }}</span>
<span v-if="configFlags(selectedRow).length === 0" class="text-sm text-foreground/60">No enabled flags</span>
</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 player config" 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>
<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-foreground/70">Owner user ID</label>
<AdminInput v-model="createForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-foreground/70">Name</label>
<AdminInput v-model="createForm.name" placeholder="Default player preset" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.autoplay" type="checkbox" class="h-4 w-4" /> Autoplay</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.loop" type="checkbox" class="h-4 w-4" /> Loop</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.muted" type="checkbox" class="h-4 w-4" /> Muted</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.showControls" type="checkbox" class="h-4 w-4" /> Show controls</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.pip" type="checkbox" class="h-4 w-4" /> PiP</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.airplay" type="checkbox" class="h-4 w-4" /> AirPlay</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.chromecast" type="checkbox" class="h-4 w-4" /> Chromecast</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.encrytionM3u8" type="checkbox" class="h-4 w-4" /> Encrypted HLS</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" /> Active</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" /> Default</label>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-foreground/70">Logo URL</label>
<AdminInput v-model="createForm.logoUrl" placeholder="https://example.com/logo.png" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="editOpen" title="Edit player config" 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>
<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-foreground/70">Owner user ID</label>
<AdminInput v-model="editForm.userId" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-foreground/70">Name</label>
<AdminInput v-model="editForm.name" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="editForm.description" rows="3" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.autoplay" type="checkbox" class="h-4 w-4" /> Autoplay</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.loop" type="checkbox" class="h-4 w-4" /> Loop</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.muted" type="checkbox" class="h-4 w-4" /> Muted</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.showControls" type="checkbox" class="h-4 w-4" /> Show controls</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.pip" type="checkbox" class="h-4 w-4" /> PiP</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.airplay" type="checkbox" class="h-4 w-4" /> AirPlay</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.chromecast" type="checkbox" class="h-4 w-4" /> Chromecast</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.encrytionM3u8" type="checkbox" class="h-4 w-4" /> Encrypted HLS</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" /> Active</label>
<label class="flex items-center gap-2 text-sm text-foreground/70"><input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" /> Default</label>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-foreground/70">Logo URL</label>
<AdminInput v-model="editForm.logoUrl" placeholder="https://example.com/logo.png" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="deleteOpen" title="Delete player config" 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-foreground/70">Delete <span class="font-semibold text-foreground">{{ selectedRow?.name }}</span>? This action cannot be undone.</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -2,18 +2,22 @@
import { client, client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminSelect from "./components/AdminSelect.vue";
import AdminTable from "./components/AdminTable.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import AdminUserFormFields from "./components/AdminUserFormFields.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
import AsyncSelect from "@/components/ui/AsyncSelect.vue";
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
type GetAdminUserResponse = Awaited<ReturnType<typeof rpcClient.getAdminUser>>;
type AdminUserDetail = NonNullable<GetAdminUserResponse["user"]>;
const roleOptions = ["USER", "ADMIN"] as const;
const roleFilterOptions = ["", ...roleOptions] as const;
@@ -27,6 +31,7 @@ const total = ref(0);
const limit = ref(12);
const page = ref(1);
const selectedRow = ref<AdminUserRow | null>(null);
const selectedDetail = ref<AdminUserDetail | null>(null);
const search = ref("");
const appliedSearch = ref("");
const roleFilter = ref<(typeof roleFilterOptions)[number]>("");
@@ -36,6 +41,7 @@ const detailOpen = ref(false);
const editOpen = ref(false);
const roleOpen = ref(false);
const deleteOpen = ref(false);
const referralOpen = ref(false);
const createForm = reactive({
email: "",
@@ -59,18 +65,32 @@ const roleForm = reactive({
role: "USER",
});
const referralForm = reactive({
id: "",
refUsername: "",
clearReferrer: false,
referralEligible: true,
rewardPercent: "",
clearRewardPercent: false,
});
const canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim());
const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.role.trim());
const canUpdateReferral = computed(() => referralForm.id.trim());
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
if (!selectedDetail.value?.user && !selectedRow.value) return [];
const user = selectedDetail.value?.user || selectedRow.value;
const referral = selectedDetail.value?.referral;
return [
{ label: "Role", value: selectedRow.value.role || "USER" },
{ label: "Plan", value: selectedRow.value.planName || selectedRow.value.planId || "Free" },
{ label: "Videos", value: String(selectedRow.value.videoCount ?? 0) },
{ label: "Wallet", value: String(selectedRow.value.walletBalance ?? 0) },
{ label: "Created", value: formatDate(selectedRow.value.createdAt) },
{ label: "Role", value: user?.role || "USER" },
{ label: "Plan", value: user?.planName || user?.planId || "Free" },
{ label: "Videos", value: String(user?.videoCount ?? 0) },
{ label: "Wallet", value: String(user?.walletBalance ?? 0) },
{ label: "Referrer", value: referral?.referrer?.username ? `@${referral.referrer.username}` : referral?.referrer?.email || "—" },
{ label: "Reward", value: referral?.rewardGranted ? `${referral.rewardAmount ?? 0} USD` : "Pending / none" },
{ label: "Created", value: formatDate(user?.createdAt) },
];
});
@@ -89,6 +109,11 @@ const normalizeOptional = (value: string) => {
return trimmed ? trimmed : undefined;
};
const planOptionsLoader = () =>
client.listPlans().then((plans) =>
(plans?.plans || []).map((plan) => ({ label: plan.name!, value: plan.id! })),
);
const resetCreateForm = () => {
createForm.email = "";
createForm.username = "";
@@ -140,10 +165,22 @@ const applyFilters = async () => {
await loadUsers();
};
const openDetailDialog = (row: AdminUserRow) => {
const loadUserDetail = async (userId: string) => {
const response = await rpcClient.getAdminUser({ id: userId });
selectedDetail.value = response.user ?? null;
return selectedDetail.value;
};
const openDetailDialog = async (row: AdminUserRow) => {
selectedRow.value = row;
selectedDetail.value = null;
actionError.value = null;
detailOpen.value = true;
try {
await loadUserDetail(row.id || '');
} catch (err: any) {
actionError.value = err?.message || 'Failed to load user details';
}
};
const openEditDialog = (row: AdminUserRow) => {
@@ -172,6 +209,24 @@ const openDeleteDialog = (row: AdminUserRow) => {
deleteOpen.value = true;
};
const openReferralDialog = async (row: AdminUserRow) => {
selectedRow.value = row;
actionError.value = null;
referralOpen.value = true;
referralForm.id = row.id || '';
referralForm.refUsername = '';
referralForm.clearReferrer = false;
referralForm.rewardPercent = '';
referralForm.clearRewardPercent = false;
try {
const detail = await loadUserDetail(row.id || '');
referralForm.referralEligible = detail?.referral?.referralEligible ?? true;
referralForm.rewardPercent = detail?.referral?.rewardOverridePercent != null ? String(detail.referral.rewardOverridePercent) : '';
} catch (err: any) {
actionError.value = err?.message || 'Failed to load referral settings';
}
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
@@ -242,6 +297,7 @@ const submitDelete = async () => {
await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
deleteOpen.value = false;
selectedRow.value = null;
selectedDetail.value = null;
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
await loadUsers();
} catch (err: any) {
@@ -251,6 +307,30 @@ const submitDelete = async () => {
}
};
const submitReferral = async () => {
if (!canUpdateReferral.value) return;
submitting.value = true;
actionError.value = null;
try {
const rewardPercent = referralForm.rewardPercent.trim();
const response = await rpcClient.updateAdminUserReferralSettings({
id: referralForm.id,
refUsername: referralForm.clearReferrer ? undefined : normalizeOptional(referralForm.refUsername),
clearReferrer: referralForm.clearReferrer || undefined,
referralEligible: referralForm.referralEligible,
referralRewardBps: referralForm.clearRewardPercent || rewardPercent === '' ? undefined : Math.round(Number(rewardPercent) * 100),
clearReferralRewardBps: referralForm.clearRewardPercent || undefined,
});
selectedDetail.value = response.user ?? null;
referralOpen.value = false;
await loadUsers();
} catch (err: any) {
actionError.value = err?.message || 'Failed to update referral settings';
} finally {
submitting.value = false;
}
};
const previousPage = async () => {
if (page.value <= 1) return;
page.value -= 1;
@@ -305,7 +385,7 @@ const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
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)}`,
class: `inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ${roleBadgeClass(row.original.role)}`,
},
row.original.role || "USER"
),
@@ -351,6 +431,7 @@ const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
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: "ghost", onClick: () => openReferralDialog(row.original) }, { default: () => "Referral" }),
h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }),
]),
meta: {
@@ -365,6 +446,26 @@ watch(roleFilter, async () => {
await loadUsers();
});
useAdminPageHeader(() => ({
eyebrow: 'Access',
badge: loading.value ? 'Syncing user directory' : `${total.value} records loaded`,
actions: [
{
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadUsers,
},
{
label: 'Create user',
onClick: () => {
actionError.value = null;
createOpen.value = true;
},
},
],
}));
onMounted(loadUsers);
</script>
@@ -372,29 +473,27 @@ onMounted(loadUsers);
<AdminSectionShell>
<template #stats>
<div
<AdminMetricCard
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>
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<SettingsSectionCard title="Filters" description="Find users by email, username or role." bodyClass="p-5">
<AdminSectionCard 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" />
<label class="text-xs font-medium text-foreground/60">Search</label>
<AdminInput 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">
<label class="text-xs font-medium text-foreground/60">Role filter</label>
<AdminSelect v-model="roleFilter">
<option v-for="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option>
</select>
</AdminSelect>
</div>
</div>
<div class="flex items-center gap-2">
@@ -402,21 +501,17 @@ onMounted(loadUsers);
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton>
</div>
</div>
</SettingsSectionCard>
</AdminSectionCard>
<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>
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadUsers">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true; actionError = null">Create user</AppButton>
</template>
<AdminSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
<template v-else>
<BaseTable
<AdminTable
:data="rows"
:columns="columns"
:get-row-id="(row) => row.id || row.email || ''"
@@ -431,10 +526,10 @@ onMounted(loadUsers);
<p class="text-xs text-foreground/40">Try clearing the search term or switching the selected role.</p>
</div>
</template>
</BaseTable>
</AdminTable>
<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">
<div class="text-xs text-foreground/55">
Page {{ page }} of {{ totalPages }} · {{ total }} records
</div>
<div class="flex items-center gap-2">
@@ -443,37 +538,23 @@ onMounted(loadUsers);
</div>
</div>
</template>
</SettingsSectionCard>
</AdminSectionCard>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin 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>
<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">Email</label>
<AppInput v-model="createForm.email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Username</label>
<AppInput v-model="createForm.username" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label>
<select v-model="createForm.role" 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 roleOptions" :key="role" :value="role">{{ role }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Password</label>
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Plan</label>
<AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
</div>
</div>
<AdminUserFormFields
mode="create"
v-model:email="createForm.email"
v-model:username="createForm.username"
v-model:role="createForm.role"
v-model:password="createForm.password"
v-model:plan-id="createForm.planId"
:role-options="roleOptions"
:load-plan-options="planOptionsLoader"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
@@ -492,7 +573,7 @@ onMounted(loadUsers);
<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="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div>
</div>
@@ -500,6 +581,7 @@ onMounted(loadUsers);
<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 && openReferralDialog(selectedRow)">Referral</AppButton>
<AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
</div>
</template>
@@ -508,34 +590,16 @@ onMounted(loadUsers);
<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>
<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">Email</label>
<AppInput v-model="editForm.email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Username</label>
<AppInput v-model="editForm.username" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label>
<select v-model="editForm.role" 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 roleOptions" :key="role" :value="role">{{ role }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Reset password</label>
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Plan</label>
<AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
<!-- <select v-model="editForm.planId" 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="plan in selectedRow?.availablePlans || []" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
</select> -->
<!-- <AppInput v-model="editForm.planId" placeholder="Optional" /> -->
</div>
</div>
<AdminUserFormFields
mode="edit"
v-model:email="editForm.email"
v-model:username="editForm.username"
v-model:role="editForm.role"
v-model:password="editForm.password"
v-model:plan-id="editForm.planId"
:role-options="roleOptions"
:load-plan-options="planOptionsLoader"
/>
</div>
<template #footer>
<div class="flex justify-end gap-2">
@@ -549,10 +613,10 @@ onMounted(loadUsers);
<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="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label>
<select v-model="roleForm.role" 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">
<label class="text-sm font-medium text-foreground/70">Role</label>
<AdminSelect v-model="roleForm.role">
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
</select>
</AdminSelect>
</div>
</div>
<template #footer>
@@ -563,10 +627,42 @@ onMounted(loadUsers);
</template>
</AppDialog>
<AppDialog v-model:visible="referralOpen" title="Referral settings" maxWidthClass="max-w-lg" @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="space-y-2">
<label class="text-sm font-medium text-foreground/70">Referrer username</label>
<AdminInput v-model="referralForm.refUsername" placeholder="alice" :disabled="referralForm.clearReferrer" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="referralForm.clearReferrer" type="checkbox" />
Clear current referrer
</label>
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="referralForm.referralEligible" type="checkbox" />
Referral eligible
</label>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground/70">Reward override percent</label>
<AdminInput v-model="referralForm.rewardPercent" placeholder="5" :disabled="referralForm.clearRewardPercent" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground/70">
<input v-model="referralForm.clearRewardPercent" type="checkbox" />
Clear reward override
</label>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateReferral" @click="submitReferral">Save referral</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="deleteOpen" title="Delete user" 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">
<p class="text-sm text-foreground/70">
Delete <span class="font-medium">{{ selectedRow?.email || selectedRow?.id }}</span> and related data.
</p>
</div>

View File

@@ -2,11 +2,14 @@
import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import AdminInput from "./components/AdminInput.vue";
import AdminSelect from "./components/AdminSelect.vue";
import AdminTextarea from "./components/AdminTextarea.vue";
import AdminTable from "./components/AdminTable.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -319,7 +322,7 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
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)],
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", 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",
@@ -371,24 +374,25 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
},
]);
// 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;
// },
// },
// ],
// }));
useAdminPageHeader(() => ({
eyebrow: 'Media',
badge: loading.value ? 'Syncing media inventory' : `${total.value} total videos`,
actions: [
{
label: 'Refresh',
variant: 'secondary',
loading: loading.value,
onClick: loadVideos,
},
{
label: 'Create video',
onClick: () => {
actionError.value = null;
createOpen.value = true;
},
},
],
}));
watch(statusFilter, async () => {
page.value = 1;
@@ -402,48 +406,46 @@ onMounted(loadVideos);
<AdminSectionShell>
<template #stats>
<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>
<AdminMetricCard
v-for="item in summary"
:key="item.label"
:label="item.label"
:value="item.value"
/>
</template>
<div class="space-y-4">
<SettingsSectionCard title="Filters" description="Search videos by title and narrow by owner reference or status." bodyClass="p-5">
<AdminSectionCard 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" />
<label class="text-xs font-medium text-foreground/60">Search</label>
<AdminInput 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" />
<label class="text-xs font-medium text-foreground/60">Owner reference</label>
<AdminInput 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">
<label class="text-xs font-medium text-foreground/60">Status</label>
<AdminSelect v-model="statusFilter">
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
</select>
</AdminSelect>
</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>
</AdminSectionCard>
<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>
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadVideos">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true; actionError = null">Create video</AppButton>
</template>
<AdminSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable
<AdminTable
v-else
:data="rows"
:columns="columns"
@@ -459,16 +461,16 @@ onMounted(loadVideos);
<p class="text-xs text-foreground/40">Try a broader title or clear the owner and status filters.</p>
</div>
</template>
</BaseTable>
</AdminTable>
<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="text-xs text-foreground/55">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>
</SettingsSectionCard>
</AdminSectionCard>
</div>
</AdminSectionShell>
@@ -480,12 +482,12 @@ onMounted(loadVideos);
</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="text-[11px] font-medium text-foreground/55">{{ 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="text-[11px] font-medium text-foreground/55">Source URL</div>
<div class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.url }}</div>
</div>
</div>
@@ -503,42 +505,42 @@ onMounted(loadVideos);
<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">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" />
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AdminInput v-model="createForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label>
<select v-model="createForm.status" 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">
<label class="text-sm font-medium text-foreground/70">Status</label>
<AdminSelect v-model="createForm.status">
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Title</label>
<AppInput v-model="createForm.title" placeholder="Video title" />
<label class="text-sm font-medium text-foreground/70">Title</label>
<AdminInput v-model="createForm.title" placeholder="Video title" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Video URL</label>
<AppInput v-model="createForm.url" placeholder="https://..." />
<label class="text-sm font-medium text-foreground/70">Video URL</label>
<AdminInput v-model="createForm.url" placeholder="https://..." />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="createForm.description" rows="3" 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" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Format</label>
<AppInput v-model="createForm.format" placeholder="mp4" />
<label class="text-sm font-medium text-foreground/70">Format</label>
<AdminInput v-model="createForm.format" placeholder="mp4" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
<AppInput v-model="createForm.adTemplateId" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Ad template ID</label>
<AdminInput v-model="createForm.adTemplateId" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Size</label>
<AppInput v-model="createForm.size" type="number" placeholder="0" min="0" />
<label class="text-sm font-medium text-foreground/70">Size</label>
<AdminInput v-model="createForm.size" type="number" placeholder="0" min="0" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
<label class="text-sm font-medium text-foreground/70">Duration</label>
<AdminInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
</div>
</div>
</div>
@@ -555,42 +557,42 @@ onMounted(loadVideos);
<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">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="editForm.userId" placeholder="user-id" />
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AdminInput v-model="editForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label>
<select v-model="editForm.status" 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">
<label class="text-sm font-medium text-foreground/70">Status</label>
<AdminSelect v-model="editForm.status">
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
</select>
</AdminSelect>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Title</label>
<AppInput v-model="editForm.title" placeholder="Video title" />
<label class="text-sm font-medium text-foreground/70">Title</label>
<AdminInput v-model="editForm.title" placeholder="Video title" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Video URL</label>
<AppInput v-model="editForm.url" placeholder="https://..." />
<label class="text-sm font-medium text-foreground/70">Video URL</label>
<AdminInput v-model="editForm.url" placeholder="https://..." />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="editForm.description" rows="3" 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" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Description</label>
<AdminTextarea v-model="editForm.description" rows="3" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Format</label>
<AppInput v-model="editForm.format" placeholder="mp4" />
<label class="text-sm font-medium text-foreground/70">Format</label>
<AdminInput v-model="editForm.format" placeholder="mp4" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
<AppInput v-model="editForm.adTemplateId" placeholder="Optional" />
<label class="text-sm font-medium text-foreground/70">Ad template ID</label>
<AdminInput v-model="editForm.adTemplateId" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Size</label>
<AppInput v-model="editForm.size" type="number" placeholder="0" min="0" />
<label class="text-sm font-medium text-foreground/70">Size</label>
<AdminInput v-model="editForm.size" type="number" placeholder="0" min="0" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
<label class="text-sm font-medium text-foreground/70">Duration</label>
<AdminInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
</div>
</div>
</div>
@@ -605,7 +607,7 @@ onMounted(loadVideos);
<AppDialog v-model:visible="deleteOpen" title="Delete video" 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">
<p class="text-sm text-foreground/70">
Delete video <span class="font-medium">{{ selectedRow?.title || 'this video' }}</span>.
</p>
</div>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import AppInput from '@/components/ui/AppInput.vue';
defineProps<{
modelValue?: string | number | null;
type?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
id?: string;
name?: string;
autocomplete?: string;
min?: number | string;
max?: number | string;
step?: number | string;
maxlength?: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void;
(e: 'enter'): void;
}>();
</script>
<template>
<AppInput
:model-value="modelValue"
:type="type"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:id="id"
:name="name"
:autocomplete="autocomplete"
:min="min"
:max="max"
:step="step"
:maxlength="maxlength"
inputClass="bg-white py-1.5 text-sm text-foreground placeholder:text-foreground/45 focus:border-primary/40 focus:ring-primary/15"
@update:model-value="emit('update:modelValue', $event)"
@enter="emit('enter')"
>
<template v-if="$slots.prefix" #prefix>
<slot name="prefix" />
</template>
</AppInput>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed } from 'vue';
const props = withDefaults(defineProps<{
label: string;
value: string | number;
hint?: string;
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'neutral';
}>(), {
hint: '',
tone: 'accent',
});
const dotClass = computed(() => {
const tones = {
accent: 'bg-primary text-primary',
success: 'bg-emerald-500 text-emerald-500',
warning: 'bg-amber-500 text-amber-500',
danger: 'bg-rose-500 text-rose-500',
neutral: 'bg-slate-400 text-slate-400',
} as const;
return tones[props.tone];
});
</script>
<template>
<article class="overflow-hidden rounded-lg border border-border bg-background px-4 py-3.5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-medium text-foreground/55">
{{ label }}
</p>
<div class="mt-2 break-words text-[28px] leading-8 font-semibold text-foreground">
{{ value }}
</div>
<p v-if="hint" class="mt-2 text-xs leading-5 text-foreground/60">
{{ hint }}
</p>
</div>
<span :class="cn('mt-1 inline-flex h-2 w-2 shrink-0 rounded-full border border-current/20', dotClass)" />
</div>
</article>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import BaseTable from '@/components/ui/BaseTable.vue';
import type { ColumnDef } from '@tanstack/vue-table';
import { computed, h } from 'vue';
import AdminTable from './AdminTable.vue';
const props = withDefaults(defineProps<{
columns?: number | string[];
@@ -42,12 +42,10 @@ const tableColumns = computed<ColumnDef<SkeletonRow>[]>(() =>
</script>
<template>
<BaseTable
<AdminTable
: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>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
const props = withDefaults(defineProps<{
title?: string;
description?: string;
bodyClass?: string;
headerClass?: string;
titleClass?: string;
descriptionClass?: string;
}>(), {
title: '',
description: '',
bodyClass: '',
headerClass: '',
titleClass: '',
descriptionClass: '',
});
</script>
<template>
<SettingsSectionCard
class="admin-section-card"
:title="props.title"
:description="props.description"
:bodyClass="props.bodyClass === undefined ? 'p-4' : props.bodyClass"
:headerClass="cn('bg-header', props.headerClass)"
:titleClass="cn('text-sm font-semibold text-foreground', props.titleClass)"
:descriptionClass="cn('mt-0.5 text-xs leading-5 text-foreground/60', props.descriptionClass)"
>
<template v-if="$slots['header-actions']" #header-actions>
<slot name="header-actions" />
</template>
<slot />
</SettingsSectionCard>
</template>
<style scoped>
.admin-section-card {
border-radius: 0.5rem;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<section class="space-y-4">
<div v-if="$slots.stats" class="grid gap-3 sm:grid-cols-2 2xl:grid-cols-4">
<slot name="stats" />
</div>
<div class="min-w-0 space-y-4">
<slot />
</div>
</section>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<{
modelValue?: string | number | null;
disabled?: boolean;
id?: string;
name?: string;
class?: string;
}>(), {
modelValue: '',
disabled: false,
id: undefined,
name: undefined,
class: '',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const onChange = (event: Event) => {
emit('update:modelValue', (event.target as HTMLSelectElement).value);
};
</script>
<template>
<select
:id="props.id"
:name="props.name"
:value="props.modelValue ?? ''"
:disabled="props.disabled"
:class="cn('w-full rounded-md border border-border bg-white px-3 py-1.5 text-sm text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60', props.class)"
@change="onChange"
>
<slot />
</select>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts" generic="TData extends Record<string, any>">
import BaseTable from '@/components/ui/BaseTable.vue';
import { cn } from '@/lib/utils';
import type { ColumnDef, Row } from '@tanstack/vue-table';
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.',
tableClass: '',
wrapperClass: '',
headerRowClass: '',
bodyRowClass: '',
});
function resolveBodyRowClass(row: Row<TData>) {
const extra = typeof props.bodyRowClass === 'function'
? props.bodyRowClass(row)
: props.bodyRowClass;
return cn('border-b border-border hover:bg-header/60', extra);
}
</script>
<template>
<div class="admin-primer-table">
<BaseTable
:data="props.data"
:columns="props.columns"
:loading="props.loading"
:empty-text="props.emptyText"
:table-class="cn('w-full', props.tableClass)"
:wrapper-class="cn('border-x-0 border-t-0 rounded-none bg-transparent', props.wrapperClass)"
:header-row-class="cn('bg-header', props.headerRowClass)"
:body-row-class="resolveBodyRowClass"
:get-row-id="props.getRowId"
>
<template #loading>
<slot name="loading">
Loading...
</slot>
</template>
<template #empty>
<slot name="empty">
{{ props.emptyText }}
</slot>
</template>
</BaseTable>
</div>
</template>
<style scoped>
.admin-primer-table :deep(th) {
padding: 0.5rem 0.75rem !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 600 !important;
}
.admin-primer-table :deep(td) {
padding: 0.625rem 0.75rem !important;
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<{
modelValue?: string | number | null;
rows?: number;
placeholder?: string;
disabled?: boolean;
class?: string;
}>(), {
modelValue: '',
rows: 3,
placeholder: '',
disabled: false,
class: '',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const onInput = (event: Event) => {
emit('update:modelValue', (event.target as HTMLTextAreaElement).value);
};
</script>
<template>
<textarea
:value="props.modelValue ?? ''"
:rows="props.rows"
:placeholder="props.placeholder"
:disabled="props.disabled"
:class="cn('w-full rounded-md border border-border bg-white px-3 py-2 text-sm text-foreground placeholder:text-foreground/45 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-60', props.class)"
@input="onInput"
/>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import AsyncSelect from '@/components/ui/AsyncSelect.vue';
import AdminInput from './AdminInput.vue';
import AdminSelect from './AdminSelect.vue';
defineProps<{
mode: 'create' | 'edit';
roleOptions: readonly string[];
loadPlanOptions: () => Promise<{ label: string; value: string | number }[]>;
}>();
const email = defineModel<string>('email', { default: '' });
const username = defineModel<string>('username', { default: '' });
const role = defineModel<string>('role', { default: 'USER' });
const password = defineModel<string>('password', { default: '' });
const planId = defineModel<string>('planId', { default: '' });
</script>
<template>
<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-foreground/70">Email</label>
<AdminInput v-model="email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground/70">Username</label>
<AdminInput v-model="username" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground/70">Role</label>
<AdminSelect v-model="role">
<option v-for="item in roleOptions" :key="item" :value="item">{{ item }}</option>
</AdminSelect>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground/70">
{{ mode === 'create' ? 'Password' : 'Reset password' }}
</label>
<AdminInput
v-model="password"
type="password"
:placeholder="mode === 'create' ? 'Minimum 6 characters' : 'Leave blank to keep current'"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground/70">Plan</label>
<AsyncSelect
v-model="planId"
:load-options="loadPlanOptions"
placeholder="Select a plan"
/>
</div>
</div>
</template>

View File

@@ -3,7 +3,7 @@ import { inject, onBeforeUnmount, reactive, watchEffect, type VNode } from 'vue'
export type AdminHeaderAction = {
label: string;
icon?: string | VNode;
variant?: 'primary' | 'secondary' | 'danger';
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
onClick: () => void;
loading?: boolean;
disabled?: boolean;

View File

@@ -35,6 +35,7 @@ export interface UpdateMeRequest {
email?: string | undefined;
language?: string | undefined;
locale?: string | undefined;
telegramId?: string | undefined;
}
export interface UpdateMeResponse {
@@ -206,7 +207,7 @@ export const GetMeResponse: MessageFns<GetMeResponse> = {
};
function createBaseUpdateMeRequest(): UpdateMeRequest {
return { username: undefined, email: undefined, language: undefined, locale: undefined };
return { username: undefined, email: undefined, language: undefined, locale: undefined, telegramId: undefined };
}
export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
@@ -223,6 +224,9 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
if (message.locale !== undefined) {
writer.uint32(34).string(message.locale);
}
if (message.telegramId !== undefined) {
writer.uint32(42).string(message.telegramId);
}
return writer;
},
@@ -265,6 +269,14 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
message.locale = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.telegramId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -280,6 +292,11 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
email: isSet(object.email) ? globalThis.String(object.email) : undefined,
language: isSet(object.language) ? globalThis.String(object.language) : undefined,
locale: isSet(object.locale) ? globalThis.String(object.locale) : undefined,
telegramId: isSet(object.telegramId)
? globalThis.String(object.telegramId)
: isSet(object.telegram_id)
? globalThis.String(object.telegram_id)
: undefined,
};
},
@@ -297,6 +314,9 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
if (message.locale !== undefined) {
obj.locale = message.locale;
}
if (message.telegramId !== undefined) {
obj.telegramId = message.telegramId;
}
return obj;
},
@@ -309,6 +329,7 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
message.email = object.email ?? undefined;
message.language = object.language ?? undefined;
message.locale = object.locale ?? undefined;
message.telegramId = object.telegramId ?? undefined;
return message;
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ export interface RegisterRequest {
username?: string | undefined;
email?: string | undefined;
password?: string | undefined;
refUsername?: string | undefined;
}
export interface RegisterResponse {
@@ -67,6 +68,7 @@ export interface GetGoogleLoginUrlResponse {
export interface CompleteGoogleLoginRequest {
code?: string | undefined;
refUsername?: string | undefined;
}
export interface CompleteGoogleLoginResponse {
@@ -208,7 +210,7 @@ export const LoginResponse: MessageFns<LoginResponse> = {
};
function createBaseRegisterRequest(): RegisterRequest {
return { username: "", email: "", password: "" };
return { username: "", email: "", password: "", refUsername: undefined };
}
export const RegisterRequest: MessageFns<RegisterRequest> = {
@@ -222,6 +224,9 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
if (message.password !== undefined && message.password !== "") {
writer.uint32(26).string(message.password);
}
if (message.refUsername !== undefined) {
writer.uint32(34).string(message.refUsername);
}
return writer;
},
@@ -256,6 +261,14 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
message.password = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.refUsername = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -270,6 +283,11 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
username: isSet(object.username) ? globalThis.String(object.username) : "",
email: isSet(object.email) ? globalThis.String(object.email) : "",
password: isSet(object.password) ? globalThis.String(object.password) : "",
refUsername: isSet(object.refUsername)
? globalThis.String(object.refUsername)
: isSet(object.ref_username)
? globalThis.String(object.ref_username)
: undefined,
};
},
@@ -284,6 +302,9 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
if (message.password !== undefined && message.password !== "") {
obj.password = message.password;
}
if (message.refUsername !== undefined) {
obj.refUsername = message.refUsername;
}
return obj;
},
@@ -295,6 +316,7 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
message.username = object.username ?? "";
message.email = object.email ?? "";
message.password = object.password ?? "";
message.refUsername = object.refUsername ?? undefined;
return message;
},
};
@@ -724,7 +746,7 @@ export const GetGoogleLoginUrlResponse: MessageFns<GetGoogleLoginUrlResponse> =
};
function createBaseCompleteGoogleLoginRequest(): CompleteGoogleLoginRequest {
return { code: "" };
return { code: "", refUsername: undefined };
}
export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest> = {
@@ -732,6 +754,9 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
if (message.code !== undefined && message.code !== "") {
writer.uint32(10).string(message.code);
}
if (message.refUsername !== undefined) {
writer.uint32(18).string(message.refUsername);
}
return writer;
},
@@ -750,6 +775,14 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
message.code = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.refUsername = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -760,7 +793,14 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
},
fromJSON(object: any): CompleteGoogleLoginRequest {
return { code: isSet(object.code) ? globalThis.String(object.code) : "" };
return {
code: isSet(object.code) ? globalThis.String(object.code) : "",
refUsername: isSet(object.refUsername)
? globalThis.String(object.refUsername)
: isSet(object.ref_username)
? globalThis.String(object.ref_username)
: undefined,
};
},
toJSON(message: CompleteGoogleLoginRequest): unknown {
@@ -768,6 +808,9 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
if (message.code !== undefined && message.code !== "") {
obj.code = message.code;
}
if (message.refUsername !== undefined) {
obj.refUsername = message.refUsername;
}
return obj;
},
@@ -777,6 +820,7 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
fromPartial<I extends Exact<DeepPartial<CompleteGoogleLoginRequest>, I>>(object: I): CompleteGoogleLoginRequest {
const message = createBaseCompleteGoogleLoginRequest();
message.code = object.code ?? "";
message.refUsername = object.refUsername ?? undefined;
return message;
},
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { Context, Hono } from "hono";
import { deleteCookie } from "hono/cookie";
import { deleteCookie, getCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception";
import { getAuthServiceClient, getInternalGrpcMetadata } from "../services/grpcClient";
import type { User } from "@/server/gen/proto/app/v1/common";
@@ -84,6 +84,7 @@ const redirectToGoogleFinalize = (c: Context, status: string, reason?: string) =
};
authRoute.get("/google/callback", async (c) => {
const referralCookieName = "ref_username";
const oauthError = c.req.query("error")?.trim();
if (oauthError) {
return redirectToGoogleFinalize(c, "error", oauthError);
@@ -96,8 +97,9 @@ authRoute.get("/google/callback", async (c) => {
try {
const grpcCookies: string[] = [];
const refUsername = getCookie(c, referralCookieName)?.trim();
await authService().completeGoogleLogin(
{ code },
{ code, refUsername: refUsername || undefined },
getInternalGrpcMetadata(),
{
onMetadata: (metadata) => {
@@ -109,9 +111,11 @@ authRoute.get("/google/callback", async (c) => {
},
},
);
deleteCookie(c, referralCookieName, { path: "/" });
forwardGrpcCookies(c, grpcCookies);
return redirectToGoogleFinalize(c, "success");
} catch (error) {
deleteCookie(c, referralCookieName, { path: "/" });
const reason = normalizeGoogleAuthReason(error instanceof Error ? error.message : undefined);
return redirectToGoogleFinalize(c, "error", reason);
}

View File

@@ -53,6 +53,31 @@ export const adminMethods = {
const metadata = context.get("grpcMetadata");
return await adminClient.updateAdminUser(data, metadata);
}),
getAdminUser: validateFn(
z.object({
id: z.string().trim().min(1),
}),
)(async (data) => {
const context = getContext();
const adminClient = context.get("adminServiceClient");
const metadata = context.get("grpcMetadata");
return await adminClient.getAdminUser(data, metadata);
}),
updateAdminUserReferralSettings: validateFn(
z.object({
id: z.string().trim().min(1),
refUsername: optionalTrimmed(),
clearReferrer: z.boolean().optional(),
referralEligible: z.boolean().optional(),
referralRewardBps: z.number().int().min(0).max(10000).optional(),
clearReferralRewardBps: z.boolean().optional(),
}),
)(async (data) => {
const context = getContext();
const adminClient = context.get("adminServiceClient");
const metadata = context.get("grpcMetadata");
return await adminClient.updateAdminUserReferralSettings(data, metadata);
}),
updateAdminUserRole: validateFn(
z.object({
id: z.string().trim().min(1),
@@ -282,8 +307,80 @@ export const adminMethods = {
const metadata = context.get("grpcMetadata");
return await adminClient.deleteAdminAdTemplate(data, metadata);
}),
listAdminPlayerConfigs: validateFn(
z.object({
page: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(100).optional(),
userId: optionalTrimmed(),
search: optionalTrimmed(),
}).optional().default({}),
)(async (data) => {
const context = getContext();
const adminClient = context.get("adminServiceClient");
const metadata = context.get("grpcMetadata");
return await adminClient.listAdminPlayerConfigs(data, metadata);
}),
createAdminPlayerConfig: validateFn(
z.object({
userId: z.string().trim().min(1),
name: z.string().trim().min(1),
description: optionalTrimmed(),
autoplay: z.boolean().optional(),
loop: z.boolean().optional(),
muted: z.boolean().optional(),
showControls: z.boolean().optional(),
pip: z.boolean().optional(),
airplay: z.boolean().optional(),
chromecast: z.boolean().optional(),
encrytionM3u8: z.boolean().optional(),
logoUrl: z.string().trim().optional(),
isActive: z.boolean(),
isDefault: z.boolean(),
}),
)(async (data) => {
const context = getContext();
const adminClient = context.get("adminServiceClient");
const metadata = context.get("grpcMetadata");
return await adminClient.createAdminPlayerConfig(data, metadata);
}),
updateAdminPlayerConfig: validateFn(
z.object({
id: z.string().trim().min(1),
userId: z.string().trim().min(1),
name: z.string().trim().min(1),
description: optionalTrimmed(),
autoplay: z.boolean().optional(),
loop: z.boolean().optional(),
muted: z.boolean().optional(),
showControls: z.boolean().optional(),
pip: z.boolean().optional(),
airplay: z.boolean().optional(),
chromecast: z.boolean().optional(),
encrytionM3u8: z.boolean().optional(),
logoUrl: z.string().trim().optional(),
isActive: z.boolean(),
isDefault: z.boolean(),
}),
)(async (data) => {
const context = getContext();
const adminClient = context.get("adminServiceClient");
const metadata = context.get("grpcMetadata");
return await adminClient.updateAdminPlayerConfig(data, metadata);
}),
deleteAdminPlayerConfig: validateFn(
z.object({
id: z.string().trim().min(1),
}),
)(async (data) => {
const context = getContext();
const adminClient = context.get("adminServiceClient");
const metadata = context.get("grpcMetadata");
return await adminClient.deleteAdminPlayerConfig(data, metadata);
}),
listAdminJobs: validateFn(
z.object({
cursor: optionalTrimmed(),
pageSize: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
limit: z.number().int().min(1).max(100).optional(),
agentId: optionalTrimmed(),

View File

@@ -36,12 +36,18 @@ export const publicAuthMethods = {
email: z.string().email("Invalid email"),
username: z.string().min(3, "Username must be at least 3 characters"),
password: z.string().min(6, "Password must be at least 6 characters"),
refUsername: z.string().trim().min(1).optional(),
}),
)(async (data) => {
const context = getContext();
const authClient = context.get("authServiceClient");
const metadata = context.get("internalGrpcMetadata");
const response = await authClient.register(data, metadata);
const response = await authClient.register({
email: data.email,
username: data.username,
password: data.password,
refUsername: data.refUsername,
}, metadata);
return { user: ensureSessionUser(response.user) };
}),

View File

@@ -139,6 +139,67 @@ export const meMethods = {
const metadata = context.get("grpcMetadata");
return await adTemplatesClient.deleteAdTemplate(data, metadata);
}),
listPlayerConfigs: async () => {
const context = getContext();
const playerConfigsClient = context.get("playerConfigsServiceClient");
const metadata = context.get("grpcMetadata");
return await playerConfigsClient.listPlayerConfigs({}, metadata);
},
createPlayerConfig: validateFn(
z.object({
name: z.string().trim().min(1),
description: z.string().optional(),
autoplay: z.boolean().optional(),
loop: z.boolean().optional(),
muted: z.boolean().optional(),
showControls: z.boolean().optional(),
pip: z.boolean().optional(),
airplay: z.boolean().optional(),
chromecast: z.boolean().optional(),
encrytionM3u8: z.boolean().optional(),
logoUrl: z.string().trim().optional(),
isActive: z.boolean().optional(),
isDefault: z.boolean().optional(),
}),
)(async (data) => {
const context = getContext();
const playerConfigsClient = context.get("playerConfigsServiceClient");
const metadata = context.get("grpcMetadata");
return await playerConfigsClient.createPlayerConfig(data, metadata);
}),
updatePlayerConfig: validateFn(
z.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
description: z.string().optional(),
autoplay: z.boolean().optional(),
loop: z.boolean().optional(),
muted: z.boolean().optional(),
showControls: z.boolean().optional(),
pip: z.boolean().optional(),
airplay: z.boolean().optional(),
chromecast: z.boolean().optional(),
encrytionM3u8: z.boolean().optional(),
logoUrl: z.string().trim().optional(),
isActive: z.boolean().optional(),
isDefault: z.boolean().optional(),
}),
)(async (data) => {
const context = getContext();
const playerConfigsClient = context.get("playerConfigsServiceClient");
const metadata = context.get("grpcMetadata");
return await playerConfigsClient.updatePlayerConfig(data, metadata);
}),
deletePlayerConfig: validateFn(
z.object({
id: z.string().trim().min(1),
}),
)(async (data) => {
const context = getContext();
const playerConfigsClient = context.get("playerConfigsServiceClient");
const metadata = context.get("grpcMetadata");
return await playerConfigsClient.deletePlayerConfig(data, metadata);
}),
getPreferences: async () => {
const context = getContext();
const preferencesClient = context.get("preferencesServiceClient");
@@ -151,13 +212,8 @@ export const meMethods = {
pushNotifications: z.boolean().optional(),
marketingNotifications: z.boolean().optional(),
telegramNotifications: z.boolean().optional(),
autoplay: z.boolean().optional(),
loop: z.boolean().optional(),
muted: z.boolean().optional(),
showControls: z.boolean().optional(),
pip: z.boolean().optional(),
airplay: z.boolean().optional(),
chromecast: z.boolean().optional(),
language: z.string().optional(),
locale: z.string().optional(),
}),
)(async (data) => {
const context = getContext();

View File

@@ -19,9 +19,11 @@ import {
import {
AdTemplatesServiceClient,
DomainsServiceClient,
PlayerConfigsServiceClient,
PlansServiceClient,
type AdTemplatesServiceClient as AdTemplatesServiceClientType,
type DomainsServiceClient as DomainsServiceClientType,
type PlayerConfigsServiceClient as PlayerConfigsServiceClientType,
type PlansServiceClient as PlansServiceClientType,
} from "@/server/gen/proto/app/v1/catalog";
import {
@@ -45,6 +47,7 @@ declare module "hono" {
adTemplatesServiceClient: PromisifiedClient<AdTemplatesServiceClientType>;
videosServiceClient: PromisifiedClient<VideosServiceClientType>;
domainsServiceClient: PromisifiedClient<DomainsServiceClientType>;
playerConfigsServiceClient: PromisifiedClient<PlayerConfigsServiceClientType>;
plansServiceClient: PromisifiedClient<PlansServiceClientType>;
paymentsServiceClient: PromisifiedClient<PaymentsServiceClientType>;
preferencesServiceClient: PromisifiedClient<PreferencesServiceClientType>;
@@ -164,6 +167,14 @@ export const getDomainsServiceClient = () => {
return context.get("domainsServiceClient");
};
export const getPlayerConfigsServiceClient = () => {
const context = tryGetContext();
if (!context) {
throw new Error("No context available to get PlayerConfigsServiceClient");
}
return context.get("playerConfigsServiceClient");
};
export const getPlansServiceClient = () => {
const context = tryGetContext();
if (!context) {
@@ -218,6 +229,7 @@ export const setupServices = (app: Hono) => {
const adTemplatesClient = new AdTemplatesServiceClient(grpcAddress(), creds);
const videosClient = new VideosServiceClient(grpcAddress(), creds);
const domainsClient = new DomainsServiceClient(grpcAddress(), creds);
const playerConfigsClient = new PlayerConfigsServiceClient(grpcAddress(), creds);
const plansClient = new PlansServiceClient(grpcAddress(), creds);
const paymentsClient = new PaymentsServiceClient(grpcAddress(), creds);
const preferencesClient = new PreferencesServiceClient(grpcAddress(), creds);
@@ -230,6 +242,7 @@ export const setupServices = (app: Hono) => {
c.set("adTemplatesServiceClient", promisifyClient(adTemplatesClient));
c.set("videosServiceClient", promisifyClient(videosClient));
c.set("domainsServiceClient", promisifyClient(domainsClient));
c.set("playerConfigsServiceClient", promisifyClient(playerConfigsClient));
c.set("plansServiceClient", promisifyClient(plansClient));
c.set("paymentsServiceClient", promisifyClient(paymentsClient));
c.set("preferencesServiceClient", promisifyClient(preferencesClient));

View File

@@ -121,8 +121,13 @@ export const useAuthStore = defineStore("auth", () => {
}
}
async function loginWithGoogle() {
async function loginWithGoogle(refUsername?: string) {
if (typeof window === "undefined") return;
if (refUsername?.trim()) {
document.cookie = `ref_username=${encodeURIComponent(refUsername.trim())}; Path=/; Max-Age=900; SameSite=Lax`;
} else {
document.cookie = "ref_username=; Path=/; Max-Age=0; SameSite=Lax";
}
const response = await rpcClient.getGoogleLoginUrl();
if (!response.url) {
throw new Error(t("auth.errors.unknown"));
@@ -130,12 +135,12 @@ export const useAuthStore = defineStore("auth", () => {
window.location.assign(response.url);
}
async function register(username: string, email: string, password: string) {
async function register(username: string, email: string, password: string, refUsername?: string) {
loading.value = true;
error.value = null;
try {
await rpcClient.register({ username, email, password });
await rpcClient.register({ username, email, password, refUsername: refUsername?.trim() || undefined });
await router.push("/login");
} catch (e: any) {
error.value = t("auth.errors.registrationFailed", {

View File

@@ -1,5 +1,7 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",