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", "@grpc/grpc-js": "^1.14.3",
"@hattip/adapter-node": "^0.0.49", "@hattip/adapter-node": "^0.0.49",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18", "@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.0",
"@hono/node-server": "^1.19.11", "@hono/node-server": "^1.19.11",
"@hono/zod-validator": "^0.7.6", "@hono/zod-validator": "^0.7.6",
"@pinia/colada": "^1.0.0", "@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/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/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=="], "@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'] MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default'] MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
NotificationDrawer: typeof import('./src/components/NotificationDrawer.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'] PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default'] PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
PencilIcon: typeof import('./src/components/icons/PencilIcon.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'] TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadIcon: typeof import('./src/components/icons/UploadIcon.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: typeof import('./src/components/icons/UserIcon.vue')['default']
'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default'] 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
Video: typeof import('./src/components/icons/Video.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 MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default'] const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.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 PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default'] const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
const PencilIcon: typeof import('./src/components/icons/PencilIcon.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 TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadIcon: typeof import('./src/components/icons/UploadIcon.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: typeof import('./src/components/icons/UserIcon.vue')['default']
const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default'] const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default'] const Video: typeof import('./src/components/icons/Video.vue')['default']

View File

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

View File

@@ -112,7 +112,8 @@
"security": "Security", "security": "Security",
"billing": "Billing & Plans", "billing": "Billing & Plans",
"notifications": "Notifications", "notifications": "Notifications",
"player": "Player", "playerGroup": "Player",
"playerConfigs": "Player Configs",
"domains": "Allowed Domains", "domains": "Allowed Domains",
"ads": "Ads & VAST", "ads": "Ads & VAST",
"danger": "Danger Zone" "danger": "Danger Zone"
@@ -128,9 +129,9 @@
"title": "Notifications", "title": "Notifications",
"subtitle": "Choose how you want to receive notifications and updates." "subtitle": "Choose how you want to receive notifications and updates."
}, },
"player": { "preferences": {
"title": "Player Settings", "title": "Preferences",
"subtitle": "Configure default video player behavior and features." "subtitle": "Manage your account preferences and notification channels."
}, },
"billing": { "billing": {
"title": "Billing & Plans", "title": "Billing & Plans",
@@ -144,6 +145,10 @@
"title": "Ads & VAST", "title": "Ads & VAST",
"subtitle": "Create and manage VAST ad templates for your videos." "subtitle": "Create and manage VAST ad templates for your videos."
}, },
"playerConfigs": {
"title": "Player Configs",
"subtitle": "Create and manage player configurations for your videos."
},
"danger": { "danger": {
"title": "Danger Zone", "title": "Danger Zone",
"subtitle": "Irreversible and destructive actions. Be careful!" "subtitle": "Irreversible and destructive actions. Be careful!"
@@ -293,6 +298,126 @@
"failedDetail": "Failed to load or update domains." "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": { "adsVast": {
"createTemplate": "Create Template", "createTemplate": "Create Template",
"infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.", "infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.",
@@ -629,6 +754,13 @@
"toast": { "toast": {
"dismissAria": "Dismiss" "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": { "overview": {
"welcome": { "welcome": {
"title": "Hello, {{name}}", "title": "Hello, {{name}}",
@@ -638,7 +770,27 @@
"totalVideos": "Total Videos", "totalVideos": "Total Videos",
"totalViews": "Total Views", "totalViews": "Total Views",
"storageUsed": "Storage Used", "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": { "quickActions": {
"title": "Quick Actions", "title": "Quick Actions",
@@ -1008,7 +1160,7 @@
"description": "Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer." "description": "Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer."
}, },
"live": { "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.", "description": "Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.",
"status": "Live Status", "status": "Live Status",
"onAir": "On Air", "onAir": "On Air",

View File

@@ -112,7 +112,8 @@
"security": "Bảo mật", "security": "Bảo mật",
"billing": "Thanh toán & Gói", "billing": "Thanh toán & Gói",
"notifications": "Thông báo", "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", "domains": "Tên miền được phép",
"ads": "Quảng cáo & VAST", "ads": "Quảng cáo & VAST",
"danger": "Vùng nguy hiểm" "danger": "Vùng nguy hiểm"
@@ -128,9 +129,9 @@
"title": "Thông báo", "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." "subtitle": "Chọn cách bạn muốn nhận thông báo và cập nhật."
}, },
"player": { "preferences": {
"title": "Cài đặt trình phát", "title": "Tùy chọn",
"subtitle": "Cấu hình hành vinh năng mặc định của trình phát video." "subtitle": "Quản lý các tùy chọn tài khoảnnh thông báo của bạn."
}, },
"billing": { "billing": {
"title": "Thanh toán & Gói", "title": "Thanh toán & Gói",
@@ -144,6 +145,10 @@
"title": "Quảng cáo & VAST", "title": "Quảng cáo & VAST",
"subtitle": "Tạo và quản lý mẫu quảng cáo VAST cho video." "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": { "danger": {
"title": "Vùng nguy hiểm", "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!" "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." "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": { "adsVast": {
"createTemplate": "Tạo mẫu", "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.", "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": { "toast": {
"dismissAria": "Đóng" "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": { "overview": {
"welcome": { "welcome": {
"title": "Xin chào, {{name}}", "title": "Xin chào, {{name}}",
@@ -637,7 +769,23 @@
"totalVideos": "Tổng số video", "totalVideos": "Tổng số video",
"totalViews": "Tổng lượt xem", "totalViews": "Tổng lượt xem",
"storageUsed": "Dung lượng đã dùng", "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": { "quickActions": {
"title": "Thao tác nhanh", "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." "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": { "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ó.", "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", "status": "Trạng thái trực tiếp",
"onAir": "Đang phát", "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 }, { href: "/settings", label: t("nav.settings"), icon: SettingsIcon, action: null, className },
] as const; ] as const;
if (isAdmin.value) {
return [
...baseLinks,
{
href: "/admin/overview",
label: "Admin Console",
icon: LayoutDashboard,
action: null,
className,
} as const,
];
}
return baseLinks; return baseLinks;
}); });
</script> </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> <template>
<ClientOnly> <ClientOnly>
<AppTopLoadingBar /> <AppTopLoadingBar />
<OfflineOverlay />
</ClientOnly> </ClientOnly>
<router-view/> <router-view />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ClientOnly from '@/components/ClientOnly'; import ClientOnly from '@/components/ClientOnly';
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue' import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
import OfflineOverlay from '@/components/OfflineOverlay.vue'
</script> </script>

View File

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

View File

@@ -1,6 +1,6 @@
<template> <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-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" 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-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> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ defineProps<{

View File

@@ -1,5 +1,5 @@
<template> <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> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>

View File

@@ -1,5 +1,5 @@
<template> <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> <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> </template>
<script lang="ts" setup> <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"> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
<path <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" 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 <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" 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>
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468"> <svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
<path <path

View File

@@ -1,13 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type UiButtonSize = 'sm' | 'md' | 'lg'; type UiButtonSize = 'sm' | 'md' | 'lg';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
variant?: UiButtonVariant; variant?: UiButtonVariant;
size?: UiButtonSize; size?: UiButtonSize;
block?: boolean; block?: boolean;
disabled?: boolean; disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
}>(), }>(),
{ {
@@ -15,10 +18,13 @@ const props = withDefaults(
size: 'md', size: 'md',
block: false, block: false,
disabled: false, disabled: false,
loading: false,
type: 'button', type: 'button',
}, },
); );
const isDisabled = computed(() => props.disabled || props.loading);
const classes = computed(() => { const classes = computed(() => {
const variants: Record<UiButtonVariant, string> = { const variants: Record<UiButtonVariant, string> = {
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25', primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
@@ -34,7 +40,7 @@ const classes = computed(() => {
}; };
return [ 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], variants[props.variant],
sizes[props.size], sizes[props.size],
props.block ? 'w-full' : '', props.block ? 'w-full' : '',
@@ -43,7 +49,13 @@ const classes = computed(() => {
</script> </script>
<template> <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 /> <slot />
</button> </button>
</template> </template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <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 { interface SelectOption {
label: string; label: string;
value: string | number; value: string | number;
@@ -14,15 +13,14 @@ interface Props {
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
placeholder: 'Vui lòng chọn...', placeholder: 'Please select...',
disabled: false disabled: false,
}); });
// Sử dụng defineModel thay cho props/emits thủ công
const modelValue = defineModel<string | number>(); const modelValue = defineModel<string | number>();
const options = ref<SelectOption[]>([]); const options = ref<SelectOption[]>([]);
const loading = ref<boolean>(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const fetchData = async () => { const fetchData = async () => {
@@ -30,29 +28,24 @@ const fetchData = async () => {
error.value = null; error.value = null;
try { try {
options.value = await props.loadOptions(); options.value = await props.loadOptions();
} catch (err) { } catch {
error.value = 'Lỗi kết nối'; error.value = 'Failed to load options';
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
onMounted(fetchData); onMounted(fetchData);
// Tự động load lại nếu hàm fetch thay đổi
watch(() => props.loadOptions, fetchData); watch(() => props.loadOptions, fetchData);
</script> </script>
<template> <template>
<div class="flex items-center gap-3"> <div class="space-y-2">
<div class="relative w-full max-w-64"> <div class="relative w-full">
<select <select
v-model="modelValue" v-model="modelValue"
:disabled="loading || disabled" :disabled="loading || disabled"
class="w-full appearance-none rounded-lg border border-gray-300 bg-white px-4 py-2 pr-10 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"
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"
> >
<option value="" disabled>{{ placeholder }}</option> <option value="" disabled>{{ placeholder }}</option>
<option <option
@@ -64,21 +57,19 @@ watch(() => props.loadOptions, fetchData);
</option> </option>
</select> </select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400"> <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-foreground/40">
<div class="i-carbon-chevron-down text-lg" /> <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> </div>
<div v-if="loading" class="flex items-center text-blue-500">
<div class="i-carbon-circle-dash animate-spin text-xl" />
</div>
<button <button
v-if="error" v-if="error"
type="button"
@click="fetchData" @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> </button>
</div> </div>
</template> </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; pushNotifications: boolean;
marketingNotifications: boolean; marketingNotifications: boolean;
telegramNotifications: boolean; telegramNotifications: boolean;
autoplay: boolean; language: string;
loop: boolean; locale: string;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
}; };
export type NotificationSettingsDraft = { export type NotificationSettingsDraft = {
@@ -26,17 +21,6 @@ export type NotificationSettingsDraft = {
telegram: boolean; telegram: boolean;
}; };
export type PlayerSettingsDraft = {
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
encrytion_m3u8: boolean;
};
type PreferencesResponse = { type PreferencesResponse = {
preferences?: Preferences; preferences?: Preferences;
}; };
@@ -46,13 +30,8 @@ const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
pushNotifications: true, pushNotifications: true,
marketingNotifications: false, marketingNotifications: false,
telegramNotifications: false, telegramNotifications: false,
autoplay: false, language: 'en',
loop: false, locale: 'en',
muted: false,
showControls: true,
pip: true,
airplay: true,
chromecast: true,
}; };
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => { const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
@@ -63,13 +42,8 @@ const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreference
pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications, pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications, marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications, telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay, language: preferences?.language ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.language,
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop, locale: preferences?.locale ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.locale,
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,
}; };
}; };
@@ -82,19 +56,6 @@ export const createNotificationSettingsDraft = (
telegram: snapshot.telegramNotifications, 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 = ( export const toNotificationPreferencesPayload = (
draft: NotificationSettingsDraft, draft: NotificationSettingsDraft,
): UpdatePreferencesRequest => ({ ): UpdatePreferencesRequest => ({
@@ -104,18 +65,6 @@ export const toNotificationPreferencesPayload = (
telegramNotifications: draft.telegram, 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() { export function useSettingsPreferencesQuery() {
return useQuery({ return useQuery({
key: () => SETTINGS_PREFERENCES_QUERY_KEY, key: () => SETTINGS_PREFERENCES_QUERY_KEY,

View File

@@ -97,3 +97,7 @@ export const getStatusSeverity = (status: string = "") => {
return 'info'; 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> <p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div> </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> <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"> <p class="mt-4 text-center text-sm text-gray-600">
{{ t('auth.signup.alreadyHave') }} {{ t('auth.signup.alreadyHave') }}
<router-link to="/login" <router-link to="/login"
@@ -50,13 +70,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { reactive, ref } from 'vue'; import { computed, reactive, ref } from 'vue';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import { useRoute } from 'vue-router';
import { z } from 'zod'; import { z } from 'zod';
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute();
const showPassword = ref(false); const showPassword = ref(false);
const { t } = useTranslation(); const { t } = useTranslation();
const refUsername = computed(() => String(route.query.ref || '').trim());
const form = reactive({ const form = reactive({
name: '', name: '',
@@ -86,6 +109,10 @@ const onFormSubmit = () => {
return; 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> </script>

View File

@@ -97,7 +97,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
<div <div
v-for="signal in signalItems" v-for="signal in signalItems"
:key="signal.label" :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"> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ signal.label }} {{ signal.label }}
@@ -211,7 +211,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</div> </div>
<div class="grid gap-6 lg:grid-cols-3"> <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"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-8 -258 529 532" fill="none">
<path <path
@@ -228,7 +228,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</p> </p>
</article> </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"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 570 570" fill="none">
<path <path
@@ -249,7 +249,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</p> </p>
</article> </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"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-10 -226 532 468" fill="none">
<path <path
@@ -267,7 +267,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
</article> </article>
</div> </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 class="grid gap-6 lg:grid-cols-[1fr_0.9fr] lg:items-center">
<div> <div>
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary"> <p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">

View File

@@ -64,6 +64,11 @@ const routes: RouteData[] = [
name: "signup", name: "signup",
component: () => import("./auth/signup.vue"), component: () => import("./auth/signup.vue"),
}, },
{
path: "ref/:username",
name: "referral-entry",
beforeEnter: (to) => ({ name: "signup", query: { ref: String(to.params.username || "") } }),
},
{ {
path: "forgot", path: "forgot",
name: "forgot", name: "forgot",
@@ -177,13 +182,7 @@ const routes: RouteData[] = [
}, },
{ {
path: "player", path: "player",
name: "settings-player", redirect: { name: "settings-player-configs" },
component: () => import("./settings/PlayerSettings/PlayerSettings.vue"),
meta: {
head: {
title: "Player Settings - Holistream",
},
},
}, },
{ {
path: "domains", 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", path: "danger",
name: "settings-danger", name: "settings-danger",
@@ -215,23 +224,22 @@ const routes: RouteData[] = [
}, },
}, },
}, },
],
},
{ {
path: "admin", path: "admin",
component: () => import("./admin/Layout.vue"),
meta: { requiresAdmin: true }, meta: { requiresAdmin: true },
redirect: { name: "admin-overview" }, redirect: { name: "admin-overview" },
children: [ children: [
{ path: "overview", name: "admin-overview", component: () => import("./admin/Overview.vue") }, { path: "users", name: "admin-users", component: () => import("./settings/admin/Users.vue") },
{ path: "users", name: "admin-users", component: () => import("./admin/Users.vue") }, { path: "videos", name: "admin-videos", component: () => import("./settings/admin/Videos.vue") },
{ path: "videos", name: "admin-videos", component: () => import("./admin/Videos.vue") }, { path: "payments", name: "admin-payments", component: () => import("./settings/admin/Payments.vue") },
{ path: "payments", name: "admin-payments", component: () => import("./admin/Payments.vue") }, { path: "plans", name: "admin-plans", component: () => import("./settings/admin/Plans.vue") },
{ path: "plans", name: "admin-plans", component: () => import("./admin/Plans.vue") }, { path: "ad-templates", name: "admin-ad-templates", component: () => import("./settings/admin/AdTemplates.vue") },
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./admin/AdTemplates.vue") }, { path: "player-configs", name: "admin-player-configs", component: () => import("./settings/admin/PlayerConfigs.vue") },
{ path: "jobs", name: "admin-jobs", component: () => import("./admin/Jobs.vue") }, { path: "jobs", name: "admin-jobs", component: () => import("./settings/admin/Jobs.vue") },
{ path: "agents", name: "admin-agents", component: () => import("./admin/Agents.vue") }, { path: "agents", name: "admin-agents", component: () => import("./settings/admin/Agents.vue") },
{ path: "logs", name: "admin-logs", component: () => import("./admin/Logs.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 QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue'; import RecentVideos from './components/RecentVideos.vue';
import StatsOverview from './components/StatsOverview.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 recentVideosLoading = ref(true);
const recentVideos = ref<ModelVideo[]>([]); const recentVideos = ref<ModelVideo[]>([]);
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery(); const { data: usageSnapshot, isPending: isUsagePending, refresh } = useUsageQuery();
const stats = computed(() => ({ const stats = computed<StatProps[]>(() => [
totalVideos: usageSnapshot.value?.totalVideos ?? 0, {
totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0), title: 'overview.stats.totalVideos',
storageUsed: usageSnapshot.value?.totalStorage ?? 0, value: usageSnapshot.value?.totalVideos ?? 0,
storageLimit: 10737418240, 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 statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
@@ -34,6 +52,7 @@ const fetchDashboardData = async () => {
}; };
onMounted(() => { onMounted(() => {
refresh();
fetchDashboardData(); fetchDashboardData();
}); });
</script> </script>
@@ -44,12 +63,12 @@ onMounted(() => {
{ label: $t('pageHeader.dashboard') } { label: $t('pageHeader.dashboard') }
]" /> ]" />
<AdminOverview v-if="isAdmin(auth.user?.role)" />
<template v-else>
<StatsOverview :loading="statsLoading" :stats="stats" /> <StatsOverview :loading="statsLoading" :stats="stats" />
<QuickActions :loading="recentVideosLoading" /> <QuickActions :loading="recentVideosLoading" />
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" /> <RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
</template>
<!-- <StorageUsage :loading="loading" :stats="stats" /> --> <!-- <StorageUsage :loading="loading" :stats="stats" /> -->
</div> </div>
</template> </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> <template>
<div class="text-3xl font-bold text-gray-900 mb-1"> <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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
const auth = useAuthStore(); const auth = useAuthStore();
</script> </script>

View File

@@ -34,7 +34,12 @@ const auth = useAuthStore();
const isCopied = ref(false); const isCopied = ref(false);
const { t } = useTranslation(); 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) => { const copyToClipboard = ($event: MouseEvent) => {
if ($event.target instanceof HTMLInputElement) { if ($event.target instanceof HTMLInputElement) {

View File

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

View File

@@ -1,99 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; 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 { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada'; import { useQuery } from '@pinia/colada';
import type { ColumnDef } from '@tanstack/vue-table';
import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-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 toast = useAppToast();
const confirm = useAppConfirm(); const confirm = useAppConfirm();
const auth = useAuthStore(); const auth = useAuthStore();
const { t } = useTranslation(); const { t } = useTranslation();
interface VastTemplate { const createInitialFormData = (): CreateAdTemplateRequest => ({
id: string; name: '',
name: string; description: '',
vastUrl: string; vastTagUrl: '',
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll'; adFormat: 'pre-roll',
duration?: number; duration: undefined,
enabled: boolean; isActive: true,
isDefault: boolean; isDefault: false,
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 showAddDialog = ref(false); const showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null); const editingTemplate = ref<AdTemplate | null>(null);
const saving = ref(false); const saving = ref(false);
const deletingId = ref<string | null>(null); const deletingId = ref<string | null>(null);
const togglingId = ref<string | null>(null); const togglingId = ref<string | null>(null);
const defaultingId = ref<string | null>(null); const defaultingId = ref<string | null>(null);
const formData = ref<CreateAdTemplateRequest>(createInitialFormData());
const formData = ref({
name: '',
vastUrl: '',
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
duration: undefined as number | undefined,
isDefault: false,
});
const isFreePlan = computed(() => !auth.user?.plan_id); const isFreePlan = computed(() => !auth.user?.plan_id);
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null); 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({ const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'ad-templates'], key: () => ['settings', 'ad-templates'],
query: async () => { query: async () => {
const response = await rpcClient.listAdTemplates(); 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 isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
const canCreateTemplate = computed(() => !isFreePlan.value && !isInitialLoading.value && !isMutating.value);
const refetchTemplates = () => refetch((fetchError) => { const canEditDialog = computed(() => !isFreePlan.value && !saving.value);
throw fetchError;
});
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback; 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 = () => { const resetForm = () => {
formData.value = { formData.value = createInitialFormData();
name: '',
vastUrl: '',
adFormat: 'pre-roll',
duration: undefined,
isDefault: false,
};
editingTemplate.value = null; editingTemplate.value = null;
}; };
@@ -148,33 +102,40 @@ const openAddDialog = () => {
showAddDialog.value = true; showAddDialog.value = true;
}; };
const openEditDialog = (template: VastTemplate) => { const applyTemplateToForm = (template: AdTemplate) => {
if (!ensurePaidPlan()) return;
formData.value = { formData.value = {
name: template.name, name: template.name || '',
vastUrl: template.vastUrl, description: template.description || '',
adFormat: template.adFormat, vastTagUrl: template.vastTagUrl || '',
adFormat: template.adFormat || 'pre-roll',
duration: template.duration, 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; editingTemplate.value = template;
showAddDialog.value = true; showAddDialog.value = true;
}; };
const buildRequestBody = (enabled = true) => ({ const buildRequestBody = (enabled = true): Parameters<typeof rpcClient.createAdTemplate>[0] => ({
name: formData.value.name.trim(), ...formData.value,
name: (formData.value.name || '').trim(),
description: '', description: '',
vastTagUrl: formData.value.vastUrl.trim(), vastTagUrl: (formData.value.vastTagUrl || '').trim(),
adFormat: formData.value.adFormat, adFormat: formData.value.adFormat || 'pre-roll',
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined, duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
isActive: enabled, isActive: enabled,
isDefault: enabled ? formData.value.isDefault : false, isDefault: enabled ? Boolean(formData.value.isDefault) : false,
}); });
const handleSave = async () => { const handleSave = async () => {
if (saving.value || !ensurePaidPlan()) return; if (saving.value || !ensurePaidPlan()) return;
if (!formData.value.name.trim()) { if (!(formData.value.name || '').trim()) {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('settings.adsVast.toast.nameRequiredSummary'), summary: t('settings.adsVast.toast.nameRequiredSummary'),
@@ -183,7 +144,7 @@ const handleSave = async () => {
}); });
return; return;
} }
if (!formData.value.vastUrl.trim()) { if (!(formData.value.vastTagUrl || '').trim()) {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('settings.adsVast.toast.urlRequiredSummary'), summary: t('settings.adsVast.toast.urlRequiredSummary'),
@@ -193,7 +154,7 @@ const handleSave = async () => {
return; return;
} }
try { try {
new URL(formData.value.vastUrl); new URL(formData.value.vastTagUrl || '');
} catch { } catch {
toast.add({ toast.add({
severity: 'error', severity: 'error',
@@ -217,8 +178,8 @@ const handleSave = async () => {
try { try {
if (editingTemplate.value) { if (editingTemplate.value) {
await rpcClient.updateAdTemplate({ await rpcClient.updateAdTemplate({
id: editingTemplate.value.id, id: editingTemplate.value.id || '',
...buildRequestBody(editingTemplate.value.enabled), ...buildRequestBody(Boolean(editingTemplate.value.isActive)),
}); });
toast.add({ toast.add({
severity: 'success', severity: 'success',
@@ -236,7 +197,7 @@ const handleSave = async () => {
}); });
} }
await refetchTemplates(); await refetch();
closeDialog(); closeDialog();
} catch (value: any) { } catch (value: any) {
console.error(value); 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; if (!ensurePaidPlan()) return;
togglingId.value = template.id; togglingId.value = template.id || null;
try { try {
await rpcClient.updateAdTemplate({ await rpcClient.updateAdTemplate({
id: template.id, id: template.id || '',
name: template.name, name: template.name || '',
description: '', description: template.description || '',
vastTagUrl: template.vastUrl, vastTagUrl: template.vastTagUrl || '',
adFormat: template.adFormat, adFormat: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined, duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
isActive: nextValue, isActive: nextValue,
isDefault: nextValue ? template.isDefault : false, isDefault: nextValue ? Boolean(template.isDefault) : false,
}); });
await refetchTemplates(); await refetch();
toast.add({ toast.add({
severity: 'info', severity: 'info',
summary: nextValue summary: nextValue
? t('settings.adsVast.toast.enabledSummary') ? t('settings.adsVast.toast.enabledSummary')
: t('settings.adsVast.toast.disabledSummary'), : t('settings.adsVast.toast.disabledSummary'),
detail: t('settings.adsVast.toast.toggleDetail', { detail: t('settings.adsVast.toast.toggleDetail', {
name: template.name, name: template.name || '',
state: nextValue state: nextValue
? t('settings.adsVast.state.enabled') ? t('settings.adsVast.state.enabled')
: t('settings.adsVast.state.disabled'), : t('settings.adsVast.state.disabled'),
@@ -284,27 +245,27 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
} }
}; };
const handleSetDefault = async (template: VastTemplate) => { const handleSetDefault = async (template: AdTemplate) => {
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return; if (Boolean(template.isDefault) || !Boolean(template.isActive) || !ensurePaidPlan()) return;
defaultingId.value = template.id; defaultingId.value = template.id || null;
try { try {
await rpcClient.updateAdTemplate({ await rpcClient.updateAdTemplate({
id: template.id, id: template.id || '',
name: template.name, name: template.name || '',
description: '', description: template.description || '',
vastTagUrl: template.vastUrl, vastTagUrl: template.vastTagUrl || '',
adFormat: template.adFormat, adFormat: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined, duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
isActive: template.enabled, isActive: template.isActive,
isDefault: true, isDefault: true,
}); });
await refetchTemplates(); await refetch();
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: t('settings.adsVast.toast.defaultUpdatedSummary'), 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, life: 3000,
}); });
} catch (value: any) { } catch (value: any) {
@@ -315,19 +276,19 @@ const handleSetDefault = async (template: VastTemplate) => {
} }
}; };
const handleDelete = (template: VastTemplate) => { const handleDelete = (template: AdTemplate) => {
if (!ensurePaidPlan()) return; if (!ensurePaidPlan()) return;
confirm.require({ 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'), header: t('settings.adsVast.confirm.deleteHeader'),
acceptLabel: t('settings.adsVast.confirm.deleteAccept'), acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
rejectLabel: t('settings.adsVast.confirm.deleteReject'), rejectLabel: t('settings.adsVast.confirm.deleteReject'),
accept: async () => { accept: async () => {
deletingId.value = template.id; deletingId.value = template.id || null;
try { try {
await rpcClient.deleteAdTemplate({ id: template.id }); await rpcClient.deleteAdTemplate({ id: template.id || '' });
await refetchTemplates(); await refetch();
toast.add({ toast.add({
severity: 'info', severity: 'info',
summary: t('settings.adsVast.toast.deletedSummary'), 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> </script>
<template> <template>
@@ -504,150 +313,36 @@ const columns = computed<ColumnDef<VastTemplate>[]>(() => [
bodyClass="" bodyClass=""
> >
<template #header-actions> <template #header-actions>
<AppButton size="sm" :disabled="isFreePlan || isInitialLoading || isMutating" @click="openAddDialog"> <AdsVastToolbar :disabled="!canCreateTemplate" @create="openAddDialog" />
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ t('settings.adsVast.createTemplate') }}
</AppButton>
</template> </template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70"> <AdsVastNotices :is-free-plan="isFreePlan" />
{{ t('settings.adsVast.infoBanner') }}
</SettingsNotice>
<SettingsNotice <AdsVastTable
v-if="isFreePlan" :templates="templates"
tone="warning" :is-initial-loading="isInitialLoading"
:title="t('settings.adsVast.readOnlyTitle')" :is-read-only="isFreePlan"
class="rounded-none border-x-0 border-t-0 p-3" :is-mutating="isMutating"
contentClass="text-xs text-foreground/70" :saving="saving"
> :deleting-id="deletingId"
{{ t('settings.adsVast.readOnlyMessage') }} :toggling-id="togglingId"
</SettingsNotice> :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" /> <AdsVastDialog
<BaseTable
v-else
:data="templates"
:columns="columns"
:get-row-id="(row) => row.id"
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
tableClass="w-full"
headerRowClass="bg-muted/30"
bodyRowClass="border-b border-border hover:bg-muted/30"
>
<template #empty>
<div class="px-6 py-12 text-center">
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.adsVast.emptyTitle') }}</p>
<p class="text-xs text-foreground/40">{{ t('settings.adsVast.emptySubtitle') }}</p>
</div>
</template>
</BaseTable>
<AppDialog
:visible="showAddDialog" :visible="showAddDialog"
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')" :editing-template="editingTemplate"
maxWidthClass="max-w-lg" :form-data="formData"
:saving="saving"
:can-edit="canEditDialog"
@update:visible="showAddDialog = $event" @update:visible="showAddDialog = $event"
@update:form-data="formData = $event"
@save="handleSave"
@close="closeDialog" @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> </SettingsSectionCard>
</template> </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 { useUsageQuery } from '@/composables/useUsageQuery';
import { formatBytes } from '@/lib/utils'; import { formatBytes } from '@/lib/utils';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue'; import BillingHistorySection from '@/routes/settings/Billing/components/BillingHistorySection.vue';
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue'; import BillingPlansSection from '@/routes/settings/Billing/components/BillingPlansSection.vue';
import BillingTopupDialog from '@/routes/settings/components/billing/BillingTopupDialog.vue'; import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
import BillingUsageSection from '@/routes/settings/components/billing/BillingUsageSection.vue'; import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
import BillingWalletRow from '@/routes/settings/components/billing/BillingWalletRow.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 type { Plan as ModelPlan, PaymentHistoryItem as PaymentHistoryApiItem } from '@/server/gen/proto/app/v1/common';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada'; import { useQuery } from '@pinia/colada';

View File

@@ -16,7 +16,6 @@ defineProps<{
hint: string; hint: string;
cancelLabel: string; cancelLabel: string;
proceedLabel: string; proceedLabel: string;
formatMoney: (amount: number) => string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -44,14 +43,14 @@ const emit = defineEmits<{
v-for="preset in presets" v-for="preset in presets"
:key="preset" :key="preset"
:class="[ :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 amount === preset
? 'bg-primary text-primary-foreground' ? 'bg-primary text-white'
: 'bg-muted/50 text-foreground hover:bg-muted' : 'bg-muted/50 text-foreground hover:bg-muted'
]" ]"
@click="emit('selectPreset', preset)" @click="emit('selectPreset', preset)"
> >
{{ formatMoney(preset) }} ${{ preset }}
</button> </button>
</div> </div>

View File

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

View File

@@ -1,68 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; 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 { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
import { useQuery } from '@pinia/colada'; import { useQuery } from '@pinia/colada';
import type { ColumnDef } from '@tanstack/vue-table';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import { computed, ref, watch } from '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 toast = useAppToast();
const confirm = useAppConfirm(); const confirm = useAppConfirm();
const { t } = useTranslation(); const { t } = useTranslation();
type DomainApiItem = {
id?: string;
name?: string;
created_at?: string;
};
type DomainItem = {
id: string;
name: string;
addedAt: string;
};
const newDomain = ref(''); const newDomain = ref('');
const showAddDialog = ref(false); const showAddDialog = ref(false);
const adding = ref(false); const adding = ref(false);
const removingId = ref<string | null>(null); 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({ const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'domains'], key: () => ['settings', 'domains'],
query: async () => { query: async () => {
@@ -73,13 +33,8 @@ const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
const domains = computed(() => domainsSnapshot.value || []); const domains = computed(() => domainsSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !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 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) => { watch(error, (value, previous) => {
if (!value || value === previous || adding.value || removingId.value !== null) return; if (!value || value === previous || adding.value || removingId.value !== null) return;
@@ -105,7 +60,7 @@ const handleAddDomain = async () => {
if (adding.value) return; if (adding.value) return;
const domainName = normalizeDomainInput(newDomain.value); const domainName = normalizeDomainInput(newDomain.value);
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) { if (!domainName || !domainName.includes('.') || /[/\s]/.test(domainName)) {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('settings.domainsDns.toast.invalidSummary'), summary: t('settings.domainsDns.toast.invalidSummary'),
@@ -132,7 +87,7 @@ const handleAddDomain = async () => {
name: domainName, name: domainName,
}); });
await refetchDomains(); await refetch();
closeAddDialog(); closeAddDialog();
toast.add({ toast.add({
severity: 'success', severity: 'success',
@@ -181,7 +136,7 @@ const handleRemoveDomain = (domain: DomainItem) => {
removingId.value = domain.id; removingId.value = domain.id;
try { try {
await rpcClient.deleteDomain({ id: domain.id }); await rpcClient.deleteDomain({ id: domain.id });
await refetchDomains(); await refetch();
toast.add({ toast.add({
severity: 'info', severity: 'info',
summary: t('settings.domainsDns.toast.removedSummary'), summary: t('settings.domainsDns.toast.removedSummary'),
@@ -222,49 +177,6 @@ const copyIframeCode = async () => {
life: 2000, 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> </script>
<template> <template>
@@ -274,96 +186,33 @@ const columns = computed<ColumnDef<DomainItem>[]>(() => [
bodyClass="" bodyClass=""
> >
<template #header-actions> <template #header-actions>
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog"> <DomainsDnsToolbar
<template #icon> :loading="adding"
<PlusIcon class="w-4 h-4" /> :disabled="isInitialLoading || removingId !== null"
</template> @create="openAddDialog"
{{ 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"
/> />
<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> </template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton> <DomainsDnsNotices />
</div>
</template> <DomainsDnsTable
</AppDialog> :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> </SettingsSectionCard>
</template> </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 isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value); const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
const refetchPreferences = () => refetch((fetchError) => {
throw fetchError;
});
watch(preferencesSnapshot, (snapshot) => { watch(preferencesSnapshot, (snapshot) => {
if (!snapshot) return; if (!snapshot) return;
notificationSettings.value = createNotificationSettingsDraft(snapshot); notificationSettings.value = createNotificationSettingsDraft(snapshot);
@@ -93,7 +89,7 @@ const handleSave = async () => {
await rpcClient.updatePreferences( await rpcClient.updatePreferences(
toNotificationPreferencesPayload(notificationSettings.value), toNotificationPreferencesPayload(notificationSettings.value),
); );
await refetchPreferences(); await refetch();
toast.add({ toast.add({
severity: 'success', 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"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { onMounted } from 'vue';
import AppButton from '@/components/ui/AppButton.vue'; import { useRouter } from 'vue-router';
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';
const toast = useAppToast(); const router = useRouter();
const { t } = useTranslation();
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery(); onMounted(() => {
router.replace({ name: 'settings-player-configs' });
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,
});
}); });
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> </script>
<template> <template>
<SettingsSectionCard <div class="p-4 text-sm text-foreground/60">Redirecting...</div>
: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>
</template> </template>

View File

@@ -1,19 +1,19 @@
<script setup lang="ts"> <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 { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import { supportedLocales } from '@/i18n/constants'; import { supportedLocales } from '@/i18n/constants';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import { computed, ref } from '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 auth = useAuthStore();
const toast = useAppToast(); const toast = useAppToast();
@@ -191,275 +191,50 @@ const disconnectTelegram = async () => {
:title="t('settings.securityConnected.header.title')" :title="t('settings.securityConnected.header.title')"
:description="t('settings.securityConnected.header.subtitle')" :description="t('settings.securityConnected.header.subtitle')"
> >
<SettingsRow <SecurityAccountStatusRow />
: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> <SecurityLanguageRow
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span> :selected-language="selectedLanguage"
</template> :language-options="languageOptions"
</SettingsRow> :language-saving="languageSaving"
@update:selected-language="selectedLanguage = $event"
@save="saveLanguage"
/>
<SettingsRow <SecurityChangePasswordRow @open="openChangePassword" />
: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> <SecurityEmailRow :email-connected="emailConnected" />
<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>
<SettingsRow <SecurityTelegramRow
:title="t('settings.securityConnected.changePassword.label')" :telegram-connected="telegramConnected"
:description="t('settings.securityConnected.changePassword.detail')" :telegram-username="telegramUsername"
> @connect="connectTelegram"
<template #icon> @disconnect="disconnectTelegram"
<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> <SecurityLogoutRow @logout="handleLogout" />
<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>
</SettingsSectionCard> </SettingsSectionCard>
<AppDialog <SecurityTwoFactorDialog
:visible="twoFactorDialogVisible" :visible="twoFactorDialogVisible"
:two-factor-code="twoFactorCode"
:two-factor-secret="twoFactorSecret"
@update:visible="twoFactorDialogVisible = $event" @update:visible="twoFactorDialogVisible = $event"
:title="t('settings.securityConnected.twoFactorDialog.title')" @update:two-factor-code="twoFactorCode = $event"
maxWidthClass="max-w-md" @confirm="confirmTwoFactor"
>
<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"
/> />
</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" :visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event" :current-password="currentPassword"
:title="t('settings.securityConnected.changePassword.dialog.title')" :new-password="newPassword"
maxWidthClass="max-w-md" :confirm-password="confirmPassword"
>
<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"
:loading="changePasswordLoading" :loading="changePasswordLoading"
@click="changePassword" :error="changePasswordError"
> @update:visible="changePasswordDialogVisible = $event"
<template #icon> @update:current-password="currentPassword = $event"
<CheckIcon class="w-4 h-4" /> @update:new-password="newPassword = $event"
</template> @update:confirm-password="confirmPassword = $event"
{{ t('settings.securityConnected.changePassword.dialog.submit') }} @submit="changePassword"
</AppButton> />
</div>
</template>
</AppDialog>
</template> </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> <template>
<section> <section>
<PageHeader <PageHeader :title="content[route.name as keyof typeof content]?.title || t('settings.content.fallbackTitle')"
: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')" :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="max-w-7xl mx-auto pb-12">
<div class="flex flex-col md:flex-row gap-8 mt-6"> <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" /> <UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div> </div>
<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> <p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div> </div>
</div> </div>
<nav class="space-y-6"> <nav class="space-y-6">
<div v-for="section in menuSections" :key="section.title"> <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 }} {{ section.title }}
</h3> </h3>
<ul class="space-y-0.5"> <ul class="space-y-0.5">
<li v-for="item in section.items" :key="item.value"> <li v-for="item in section.items" :key="item.value">
<router-link <router-link :to="item.to" :class="[
:to="tabPaths[item.value]"
:class="[
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150', 'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
currentTab === item.value currentTab === item.value
? 'bg-primary/10 text-primary font-semibold' ? 'bg-primary/10 text-primary font-semibold'
: item.danger : item.danger
? 'text-danger hover:bg-danger/10' ? 'text-danger hover:bg-danger/10'
: 'text-foreground/70 hover:bg-header hover:text-foreground' : 'text-foreground/70 hover:bg-header hover:text-foreground'
]" ]">
> <component :is="item.icon" class="w-5 h-5 shrink-0"
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" /> :filled="currentTab === item.value" />
{{ item.label }} {{ item.label }}
</router-link> </router-link>
</li> </li>
@@ -77,71 +75,100 @@ import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import { computed, createStaticVNode } from 'vue'; import { computed, createStaticVNode } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { isAdmin } from '@/lib/utils';
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const { t } = useTranslation(); 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) // 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'), title: t('settings.menu.securityGroup'),
items: [ 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'), title: t('settings.menu.preferencesGroup'),
items: [ items: [
{ value: 'notifications', label: t('settings.menu.notifications'), icon: Bell }, { to: '/settings/notifications', value: 'notifications', label: t('settings.menu.notifications'), icon: Bell },
{ value: 'player', label: t('settings.menu.player'), icon: VideoPlayIcon }, ],
},
{
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'), title: t('settings.menu.integrationsGroup'),
items: [ items: [
{ value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon }, { to: '/settings/domains', value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon },
{ value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon }, { 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'), title: t('settings.menu.dangerGroup'),
items: [ 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); ] 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 // Get current tab from route path
const currentTab = computed<TabValue>(() => { const currentTab = computed<TabValue>(() => {
const path = route.path as string; const path = route.path as string;
const tabName = path.replace('/settings', '') || '/profile'; 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'; if (tabName === '' || tabName === '/') return 'profile';
return (tabName.replace('/', '') as TabValue) || 'profile'; return (tabName.replace('/', '') as TabValue) || 'profile';
}); });
// Breadcrumbs with dynamic tab // 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 currentItem = computed(() => allMenuItems.value.find(item => item.value === currentTab.value));
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ label: t('pageHeader.dashboard'), to: '/overview' }, { label: t('pageHeader.dashboard'), to: '/overview' },
{ label: t('pageHeader.settings'), to: '/settings' }, { 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(() => ({ const content = computed(() => ({
@@ -153,10 +180,6 @@ const content = computed(() => ({
title: t('settings.content.notifications.title'), title: t('settings.content.notifications.title'),
subtitle: t('settings.content.notifications.subtitle') subtitle: t('settings.content.notifications.subtitle')
}, },
'settings-player': {
title: t('settings.content.player.title'),
subtitle: t('settings.content.player.subtitle')
},
'settings-billing': { 'settings-billing': {
title: t('settings.content.billing.title'), title: t('settings.content.billing.title'),
subtitle: t('settings.content.billing.subtitle') subtitle: t('settings.content.billing.subtitle')
@@ -169,9 +192,53 @@ const content = computed(() => ({
title: t('settings.content.ads.title'), title: t('settings.content.ads.title'),
subtitle: t('settings.content.ads.subtitle') subtitle: t('settings.content.ads.subtitle')
}, },
'settings-player-configs': {
title: t('settings.content.playerConfigs.title'),
subtitle: t('settings.content.playerConfigs.subtitle')
},
'settings-danger': { 'settings-danger': {
title: t('settings.content.danger.title'), title: t('settings.content.danger.title'),
subtitle: t('settings.content.danger.subtitle') 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> </script>

View File

@@ -2,11 +2,14 @@
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AdminInput from "./components/AdminInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue"; import AdminSelect from "./components/AdminSelect.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.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 { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref } from "vue"; import { computed, h, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -310,24 +313,25 @@ const columns = computed<ColumnDef<AdminAdTemplateRow>[]>(() => [
}, },
]); ]);
// useAdminPageHeader(() => ({ useAdminPageHeader(() => ({
// eyebrow: "Advertising", eyebrow: 'Advertising',
// badge: `${total.value} total templates`, badge: loading.value ? 'Syncing template inventory' : `${total.value} total templates`,
// actions: [ actions: [
// { {
// label: "Refresh", label: 'Refresh',
// variant: "secondary", variant: 'secondary',
// onClick: loadTemplates, loading: loading.value,
// }, onClick: loadTemplates,
// { },
// label: "Create template", {
// onClick: () => { label: 'Create template',
// actionError.value = null; onClick: () => {
// createOpen.value = true; actionError.value = null;
// }, createOpen.value = true;
// }, },
// ], },
// })); ],
}));
onMounted(loadTemplates); onMounted(loadTemplates);
</script> </script>
@@ -336,40 +340,38 @@ onMounted(loadTemplates);
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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="grid gap-3 xl:grid-cols-[minmax(0,1fr)_220px_auto] xl:items-end">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label> <label class="text-xs font-medium text-foreground/60">Search</label>
<AppInput v-model="search" placeholder="Search template name" @enter="applyFilters" /> <AdminInput v-model="search" placeholder="Search template name" @enter="applyFilters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Owner reference</label> <label class="text-xs font-medium text-foreground/60">Owner reference</label>
<AppInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" /> <AdminInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
</div> </div>
<div class="flex items-center gap-2 xl:justify-end"> <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="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; loadTemplates()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton> <AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div> </div>
</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> <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=""> <AdminSectionCard 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>
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" /> <AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
<BaseTable <AdminTable
v-else v-else
:data="rows" :data="rows"
:columns="columns" :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> <p class="text-xs text-foreground/40">Try a broader template name or clear the owner filter.</p>
</div> </div>
</template> </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="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"> <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 <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton> <AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div> </div>
</div> </div>
</SettingsSectionCard> </AdminSectionCard>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
@@ -406,12 +408,12 @@ onMounted(loadTemplates);
</div> </div>
<div class="grid gap-3"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-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">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 class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.vastTagUrl }}</div>
</div> </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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label> <label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" /> <AdminInput v-model="createForm.userId" placeholder="user-id" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label> <label class="text-sm font-medium text-foreground/70">Name</label>
<AppInput v-model="createForm.name" placeholder="Preroll template" /> <AdminInput v-model="createForm.name" placeholder="Preroll template" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">VAST URL</label> <label class="text-sm font-medium text-foreground/70">VAST URL</label>
<AppInput v-model="createForm.vastTagUrl" placeholder="https://..." /> <AdminInput v-model="createForm.vastTagUrl" placeholder="https://..." />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad format</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="createForm.adFormat">
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option> <option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label> <label class="text-sm font-medium text-foreground/70">Duration</label>
<AppInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" /> <AdminInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
</div> </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" /> <input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
Active Active
</label> </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" /> <input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" />
Default Default
</label> </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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label> <label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AppInput v-model="editForm.userId" /> <AdminInput v-model="editForm.userId" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label> <label class="text-sm font-medium text-foreground/70">Name</label>
<AppInput v-model="editForm.name" /> <AdminInput v-model="editForm.name" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="editForm.description" rows="3" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">VAST URL</label> <label class="text-sm font-medium text-foreground/70">VAST URL</label>
<AppInput v-model="editForm.vastTagUrl" /> <AdminInput v-model="editForm.vastTagUrl" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad format</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="editForm.adFormat">
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option> <option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label> <label class="text-sm font-medium text-foreground/70">Duration</label>
<AppInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" /> <AdminInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
</div> </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" /> <input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
Active Active
</label> </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" /> <input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" />
Default Default
</label> </label>
@@ -523,7 +525,7 @@ onMounted(loadTemplates);
<AppDialog v-model:visible="deleteOpen" title="Delete ad template" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="deleteOpen" title="Delete ad template" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Delete ad template <span class="font-medium">{{ selectedRow?.name || 'this template' }}</span>.
</p> </p>
</div> </div>

View File

@@ -2,13 +2,15 @@
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.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 { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import AdminSectionCard from "./components/AdminSectionCard.vue";
import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue";
import type { ColumnDef } from "@tanstack/vue-table"; import type { ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, ref } from "vue"; 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 AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>; type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number]; type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
@@ -164,7 +166,7 @@ const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
header: "Status", header: "Status",
accessorFn: row => row.status || "", accessorFn: row => row.status || "",
cell: ({ row }) => h("span", { 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"), }, row.original.status || "UNKNOWN"),
meta: { meta: {
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", 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 }) => { useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic !== "picpic/events") return; if (topic !== "picpic/events") return;
@@ -265,22 +280,21 @@ onMounted(loadAgents);
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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-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=""> <AdminSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
<template #header-actions> <AdminPlaceholderTable v-if="loading" :columns="8" :rows="4" />
<AppButton size="sm" variant="ghost" @click="loadAgents">Refresh</AppButton>
</template>
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
<BaseTable <AdminTable
v-else v-else
:data="rows" :data="rows"
:columns="columns" :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> <p class="text-xs text-foreground/40">Workers will appear here when they register with the admin runtime.</p>
</div> </div>
</template> </template>
</BaseTable> </AdminTable>
</SettingsSectionCard> </AdminSectionCard>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
@@ -310,18 +324,18 @@ onMounted(loadAgents);
<div class="grid gap-3"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-border bg-muted/20 px-4 py-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 class="mt-1 text-sm font-medium text-foreground">{{ formatCpu(selectedRow.cpu) }}</div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-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">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 class="mt-1 text-sm font-medium text-foreground">{{ formatRam(selectedRow.ram) }}</div>
</div> </div>
</div> </div>
@@ -338,7 +352,7 @@ onMounted(loadAgents);
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Send restart command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
</p> </p>
</div> </div>
@@ -353,7 +367,7 @@ onMounted(loadAgents);
<AppDialog v-model:visible="updateOpen" title="Update agent" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="updateOpen" title="Update agent" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Send update command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
</p> </p>
</div> </div>

View File

@@ -2,12 +2,14 @@
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AdminInput from "./components/AdminInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue"; import AdminTextarea from "./components/AdminTextarea.vue";
import AdminTable from "./components/AdminTable.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt"; 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 { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref } from "vue"; import { computed, h, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -16,10 +18,13 @@ type ListJobsResponse = Awaited<ReturnType<typeof rpcClient.listAdminJobs>>;
type AdminJobRow = NonNullable<ListJobsResponse["jobs"]>[number]; type AdminJobRow = NonNullable<ListJobsResponse["jobs"]>[number];
const loading = ref(true); const loading = ref(true);
const loadingMore = ref(false);
const submitting = ref(false); const submitting = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionError = ref<string | null>(null); const actionError = ref<string | null>(null);
const rows = ref<AdminJobRow[]>([]); const rows = ref<AdminJobRow[]>([]);
const nextCursor = ref<string | undefined>(undefined);
const hasMore = ref(false);
const selectedRow = ref<AdminJobRow | null>(null); const selectedRow = ref<AdminJobRow | null>(null);
const selectedLogs = ref(""); const selectedLogs = ref("");
const activeAgentFilter = ref(""); const activeAgentFilter = ref("");
@@ -122,11 +127,13 @@ const loadJobs = async () => {
error.value = null; error.value = null;
try { try {
const response = await rpcClient.listAdminJobs({ const response = await rpcClient.listAdminJobs({
offset: 0, cursor: undefined,
limit: 50, pageSize: 50,
agentId: appliedAgentFilter.value.trim() || undefined, agentId: appliedAgentFilter.value.trim() || undefined,
}); });
rows.value = response.jobs ?? []; rows.value = response.jobs ?? [];
nextCursor.value = response.nextCursor || undefined;
hasMore.value = Boolean(response.hasMore);
syncSelectedRow(); syncSelectedRow();
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin jobs"; 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 () => { const applyFilters = async () => {
appliedAgentFilter.value = activeAgentFilter.value; appliedAgentFilter.value = activeAgentFilter.value;
nextCursor.value = undefined;
hasMore.value = false;
await loadJobs(); await loadJobs();
}; };
@@ -272,7 +302,7 @@ const columns = computed<ColumnDef<AdminJobRow>[]>(() => [
header: "Status", header: "Status",
accessorFn: row => row.status || "", accessorFn: row => row.status || "",
cell: ({ row }) => h("span", { 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"), }, row.original.status || "UNKNOWN"),
meta: { meta: {
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", 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(() => ({ useAdminPageHeader(() => ({
eyebrow: "Runtime", eyebrow: 'Runtime',
badge: `${rows.value.length} jobs loaded`, badge: loading.value ? 'Polling queue state' : `${rows.value.length} jobs loaded`,
actions: [ actions: [
{ {
label: "Refresh", label: 'Refresh',
variant: "secondary", variant: 'secondary',
loading: loading.value,
onClick: loadJobs, onClick: loadJobs,
}, },
{ {
label: "Create job", label: 'Create job',
onClick: () => { onClick: () => {
actionError.value = null; actionError.value = null;
createOpen.value = true; createOpen.value = true;
@@ -405,37 +436,39 @@ onMounted(loadJobs);
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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="grid gap-3 xl:grid-cols-[220px_minmax(0,1fr)_auto] xl:items-end">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Assigned agent</label> <label class="text-xs font-medium text-foreground/60">Assigned agent</label>
<AppInput v-model="activeAgentFilter" placeholder="Optional agent reference" @enter="applyFilters" /> <AdminInput v-model="activeAgentFilter" placeholder="Optional agent reference" @enter="applyFilters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label> <label class="text-xs font-medium text-foreground/60">Search</label>
<AppInput v-model="search" placeholder="Search by job name or status" /> <AdminInput v-model="search" placeholder="Search by job name or status" />
</div> </div>
<div class="flex items-center gap-2 xl:justify-end"> <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> <AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div> </div>
</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> <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" /> <AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable <div v-else>
v-else <AdminTable
:data="filteredRows" :data="filteredRows"
:columns="columns" :columns="columns"
:get-row-id="(row) => row.id || row.name || ''" :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> <p class="text-xs text-foreground/40">Try a broader job name or clear the agent filter.</p>
</div> </div>
</template> </template>
</BaseTable> </AdminTable>
</SettingsSectionCard>
<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> </div>
</AdminSectionShell> </AdminSectionShell>
@@ -464,13 +503,13 @@ onMounted(loadJobs);
<div class="grid gap-3 md:grid-cols-2"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
<div class="rounded-lg border border-slate-200 bg-slate-950 px-4 py-3"> <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> <span>Live logs</span>
<button type="button" class="text-slate-300 transition hover:text-white" @click="selectedRow && openLogsDialog(selectedRow)">Open full logs</button> <button type="button" class="text-slate-300 transition hover:text-white" @click="selectedRow && openLogsDialog(selectedRow)">Open full logs</button>
</div> </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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Command</label> <label class="text-sm font-medium text-foreground/70">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 ..." /> <AdminTextarea v-model="createForm.command" rows="4" placeholder="ffmpeg -i ..." />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Image</label> <label class="text-sm font-medium text-foreground/70">Image</label>
<AppInput v-model="createForm.image" placeholder="alpine" /> <AdminInput v-model="createForm.image" placeholder="alpine" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label> <label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="Optional" /> <AdminInput v-model="createForm.userId" placeholder="Optional" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Display name</label> <label class="text-sm font-medium text-foreground/70">Display name</label>
<AppInput v-model="createForm.name" placeholder="Optional" /> <AdminInput v-model="createForm.name" placeholder="Optional" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Priority</label> <label class="text-sm font-medium text-foreground/70">Priority</label>
<AppInput v-model="createForm.priority" type="number" /> <AdminInput v-model="createForm.priority" type="number" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Time limit</label> <label class="text-sm font-medium text-foreground/70">Time limit</label>
<AppInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" /> <AdminInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Environment</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="createForm.envText" rows="5" placeholder="KEY=value per line" />
</div> </div>
</div> </div>
</div> </div>
@@ -545,7 +584,7 @@ onMounted(loadJobs);
<AppDialog v-model:visible="cancelOpen" title="Cancel job" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="cancelOpen" title="Cancel job" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Cancel <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
</p> </p>
</div> </div>
@@ -560,7 +599,7 @@ onMounted(loadJobs);
<AppDialog v-model:visible="retryOpen" title="Retry job" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="retryOpen" title="Retry job" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Retry <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
</p> </p>
</div> </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"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; 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 { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import AdminSectionCard from "./components/AdminSectionCard.vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); 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"); 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 () => { const loadLogs = async () => {
if (!jobId.value.trim()) return; if (!jobId.value.trim()) return;
loading.value = true; loading.value = true;
@@ -65,18 +81,20 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 truncate text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Job ID</label> <label class="text-xs font-medium text-foreground/60">Job ID</label>
<AppInput v-model="jobId" placeholder="job-..." @enter="loadLogs" /> <AdminInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AppButton size="sm" variant="ghost" @click="clearLogs">Reset</AppButton> <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="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="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 class="mt-1 break-all text-sm font-medium text-foreground">{{ activeChannel }}</div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm leading-6 text-foreground/70"> <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. Persisted logs are loaded once from gRPC, then appended live from MQTT frames for the same job.
</div> </div>
</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"> <div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<SettingsSectionCard title="Runtime output" :description="activeJobId || 'idle'" bodyClass="p-5"> <AdminSectionCard title="Runtime output" :description="activeJobId || 'idle'" bodyClass="p-5">
<div class="rounded-lg border border-slate-200 bg-slate-950 p-4"> <div class="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950">
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre> <div 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> </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> </div>
</AdminSectionShell> </AdminSectionShell>
</template> </template>

View File

@@ -2,13 +2,15 @@
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AdminInput from "./components/AdminInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue"; import AdminSelect from "./components/AdminSelect.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import AdminTable from "./components/AdminTable.vue";
import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.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 { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common";
import { type ColumnDef } from "@tanstack/vue-table"; import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue"; import { computed, h, onMounted, reactive, ref, watch } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -241,24 +243,25 @@ const statusBadgeClass = (status?: string) => {
} }
}; };
// useAdminPageHeader(() => ({ useAdminPageHeader(() => ({
// eyebrow: "Finance", eyebrow: 'Finance',
// badge: `${total.value} total payments`, badge: loading.value ? 'Syncing payment records' : `${total.value} payment records`,
// actions: [ actions: [
// { {
// label: "Refresh", label: 'Refresh',
// variant: "secondary", variant: 'secondary',
// onClick: loadPayments, loading: loading.value,
// }, onClick: loadPayments,
// { },
// label: "Create payment", {
// onClick: () => { label: 'Create payment',
// actionError.value = null; onClick: () => {
// createOpen.value = true; actionError.value = null;
// }, createOpen.value = true;
// }, },
// ], },
// })); ],
}));
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [ const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
{ {
@@ -309,7 +312,7 @@ const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
header: "Status", header: "Status",
accessorFn: row => row.status || "", accessorFn: row => row.status || "",
cell: ({ row }) => h("span", { 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"), }, row.original.status || "UNKNOWN"),
meta: { meta: {
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
@@ -355,44 +358,42 @@ onMounted(() => {
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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="grid gap-3 xl:grid-cols-[220px_220px_auto] xl:items-end">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">User reference</label> <label class="text-xs font-medium text-foreground/60">User reference</label>
<AppInput v-model="userFilter" placeholder="Optional user reference" @enter="applyFilters" /> <AdminInput v-model="userFilter" placeholder="Optional user reference" @enter="applyFilters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Status</label> <label class="text-xs font-medium text-foreground/60">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"> <AdminSelect v-model="statusFilter">
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option> <option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
</select> </AdminSelect>
</div> </div>
<div class="flex items-center gap-2 xl:justify-end"> <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="ghost" @click="userFilter = ''; appliedUserFilter = ''; statusFilter = ''; loadPayments()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton> <AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div> </div>
</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"> <div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass=""> <AdminSectionCard 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>
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" /> <AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable <AdminTable
v-else v-else
:data="rows" :data="rows"
:columns="columns" :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> <p class="text-xs text-foreground/40">Try a broader user reference or clear the status filter.</p>
</div> </div>
</template> </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="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"> <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 <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton> <AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div> </div>
</div> </div>
</SettingsSectionCard> </AdminSectionCard>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
@@ -430,7 +431,7 @@ onMounted(() => {
<div class="grid gap-3"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
@@ -449,22 +450,22 @@ onMounted(() => {
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">User ID</label> <label class="text-sm font-medium text-foreground/70">User ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" /> <AdminInput v-model="createForm.userId" placeholder="user-id" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Term months</label> <label class="text-sm font-medium text-foreground/70">Term months</label>
<AppInput v-model="createForm.termMonths" type="number" min="1" /> <AdminInput v-model="createForm.termMonths" type="number" min="1" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Payment method</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="createForm.paymentMethod">
<option v-for="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option> <option v-for="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Topup amount</label> <label class="text-sm font-medium text-foreground/70">Topup amount</label>
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" /> <AdminInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
</div> </div>
</div> </div>
@@ -499,10 +500,10 @@ onMounted(() => {
<div class="space-y-4"> <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 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"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="statusForm.status">
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option> <option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
</select> </AdminSelect>
</div> </div>
</div> </div>
<template #footer> <template #footer>

View File

@@ -2,11 +2,15 @@
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AdminInput from "./components/AdminInput.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.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 { computed, onMounted, reactive, ref } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
import { formatBytes } from "@/lib/utils";
type ListPlansResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlans>>; type ListPlansResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlans>>;
type AdminPlanRow = NonNullable<ListPlansResponse["plans"]>[number]; type AdminPlanRow = NonNullable<ListPlansResponse["plans"]>[number];
@@ -59,7 +63,7 @@ const summary = computed(() => [
{ label: "Plans", value: rows.value.length }, { label: "Plans", value: rows.value.length },
{ label: "Active", value: rows.value.filter((row) => row.isActive).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: "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(() => { const selectedMeta = computed(() => {
if (!selectedRow.value) return []; if (!selectedRow.value) return [];
@@ -208,6 +212,7 @@ useAdminPageHeader(() => ({
{ {
label: "Refresh", label: "Refresh",
variant: "secondary", variant: "secondary",
loading: loading.value,
onClick: loadPlans, onClick: loadPlans,
}, },
{ {
@@ -227,64 +232,70 @@ onMounted(loadPlans);
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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-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="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-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"> <div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3" v-else>
<SettingsSectionCard <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">
v-for="row in rows" <div class="p-6 border-b border-gray-100">
:key="row.id" <div class="flex justify-between items-start">
:title="row.name" <div>
:description="row.description || 'No description'" <h3 class="text-xl font-bold text-gray-900">{{ row.name }}</h3>
bodyClass="p-5" <p class="text-sm text-gray-500">{{ row.description || 'No description' }}</p>
>
<div class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-2 gap-3 flex-1 text-sm text-foreground/70">
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Price</div>
<div class="mt-1 font-semibold text-foreground">{{ row.price }}</div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3"> <span v-if="row.isActive" class="px-3 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full">Active</span>
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Cycle</div> <span v-else class="px-3 py-1 text-xs font-semibold text-red-700 bg-red-100 rounded-full">Inactive</span>
<div class="mt-1 font-semibold text-foreground">{{ row.cycle }}</div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3"> <div class="mt-4">
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Storage</div> <span class="text-3xl font-extrabold text-gray-900">${{ row.price }}</span>
<div class="mt-1 font-semibold text-foreground">{{ row.storageLimit }}</div> <span class="text-gray-500 text-sm">/{{ row.cycle }}</span>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Uploads</div>
<div class="mt-1 font-semibold text-foreground">{{ row.uploadLimit }}</div>
</div>
</div>
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="row.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-border bg-muted/40 text-foreground/70'">
{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}
</span>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3"> <div class="p-6 bg-gray-50/50 space-y-4">
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Features</div> <div class="flex items-center gap-3">
<ul class="mt-2 space-y-1 text-sm text-foreground/70"> <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-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> </ul>
</div> </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" variant="secondary" @click="openDetailDialog(row)">Details</AppButton>
<AppButton size="sm" @click="openEditDialog(row)">Edit</AppButton> <AppButton size="sm" variant="secondary" @click="openMenuDialog(row)">
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton> <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>
</div> </div>
</SettingsSectionCard>
</div> </div>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
@@ -297,12 +308,12 @@ onMounted(loadPlans);
</div> </div>
<div class="grid gap-3"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-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">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"> <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-for="feature in selectedRow.features || []" :key="feature"> {{ feature }}</li>
<li v-if="!(selectedRow.features || []).length" class="text-foreground/50">No features listed.</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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label> <label class="text-sm font-medium text-foreground/70">Name</label>
<AppInput v-model="createForm.name" placeholder="Starter" /> <AdminInput v-model="createForm.name" placeholder="Starter" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="createForm.description" :rows="3" placeholder="Optional" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Features</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="createForm.featuresText" :rows="4" placeholder="One feature per line" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Price</label> <label class="text-sm font-medium text-foreground/70">Price</label>
<AppInput v-model="createForm.price" type="number" min="0" step="0.01" /> <AdminInput v-model="createForm.price" type="number" min="0" step="0.01" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Cycle</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="createForm.cycle">
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option> <option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Storage limit</label> <label class="text-sm font-medium text-foreground/70">Storage limit</label>
<AppInput v-model="createForm.storageLimit" type="number" min="1" /> <AdminInput v-model="createForm.storageLimit" type="number" min="1" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Upload limit</label> <label class="text-sm font-medium text-foreground/70">Upload limit</label>
<AppInput v-model="createForm.uploadLimit" type="number" min="1" /> <AdminInput v-model="createForm.uploadLimit" type="number" min="1" />
</div> </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" /> <input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
Active Active
</label> </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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label> <label class="text-sm font-medium text-foreground/70">Name</label>
<AppInput v-model="editForm.name" /> <AdminInput v-model="editForm.name" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="editForm.description" :rows="3" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Features</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="editForm.featuresText" :rows="4" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Price</label> <label class="text-sm font-medium text-foreground/70">Price</label>
<AppInput v-model="editForm.price" type="number" min="0" step="0.01" /> <AdminInput v-model="editForm.price" type="number" min="0" step="0.01" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Cycle</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="editForm.cycle">
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option> <option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Storage limit</label> <label class="text-sm font-medium text-foreground/70">Storage limit</label>
<AppInput v-model="editForm.storageLimit" type="number" min="1" /> <AdminInput v-model="editForm.storageLimit" type="number" min="1" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Upload limit</label> <label class="text-sm font-medium text-foreground/70">Upload limit</label>
<AppInput v-model="editForm.uploadLimit" type="number" min="1" /> <AdminInput v-model="editForm.uploadLimit" type="number" min="1" />
</div> </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" /> <input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
Active Active
</label> </label>
@@ -417,7 +428,7 @@ onMounted(loadPlans);
<AppDialog v-model:visible="deleteOpen" title="Delete plan" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="deleteOpen" title="Delete plan" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || 'this plan' }}</span>.
</p> </p>
</div> </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 { client, client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AdminInput from "./components/AdminInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue"; import AdminSelect from "./components/AdminSelect.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import AdminTable from "./components/AdminTable.vue";
import AdminSectionCard from "./components/AdminSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table"; import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue"; import { computed, h, onMounted, reactive, ref, watch } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import AdminUserFormFields from "./components/AdminUserFormFields.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
import AsyncSelect from "@/components/ui/AsyncSelect.vue";
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>; type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number]; 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 roleOptions = ["USER", "ADMIN"] as const;
const roleFilterOptions = ["", ...roleOptions] as const; const roleFilterOptions = ["", ...roleOptions] as const;
@@ -27,6 +31,7 @@ const total = ref(0);
const limit = ref(12); const limit = ref(12);
const page = ref(1); const page = ref(1);
const selectedRow = ref<AdminUserRow | null>(null); const selectedRow = ref<AdminUserRow | null>(null);
const selectedDetail = ref<AdminUserDetail | null>(null);
const search = ref(""); const search = ref("");
const appliedSearch = ref(""); const appliedSearch = ref("");
const roleFilter = ref<(typeof roleFilterOptions)[number]>(""); const roleFilter = ref<(typeof roleFilterOptions)[number]>("");
@@ -36,6 +41,7 @@ const detailOpen = ref(false);
const editOpen = ref(false); const editOpen = ref(false);
const roleOpen = ref(false); const roleOpen = ref(false);
const deleteOpen = ref(false); const deleteOpen = ref(false);
const referralOpen = ref(false);
const createForm = reactive({ const createForm = reactive({
email: "", email: "",
@@ -59,18 +65,32 @@ const roleForm = reactive({
role: "USER", 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 canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim()); const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim());
const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.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 totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const selectedMeta = computed(() => { 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 [ return [
{ label: "Role", value: selectedRow.value.role || "USER" }, { label: "Role", value: user?.role || "USER" },
{ label: "Plan", value: selectedRow.value.planName || selectedRow.value.planId || "Free" }, { label: "Plan", value: user?.planName || user?.planId || "Free" },
{ label: "Videos", value: String(selectedRow.value.videoCount ?? 0) }, { label: "Videos", value: String(user?.videoCount ?? 0) },
{ label: "Wallet", value: String(selectedRow.value.walletBalance ?? 0) }, { label: "Wallet", value: String(user?.walletBalance ?? 0) },
{ label: "Created", value: formatDate(selectedRow.value.createdAt) }, { 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; return trimmed ? trimmed : undefined;
}; };
const planOptionsLoader = () =>
client.listPlans().then((plans) =>
(plans?.plans || []).map((plan) => ({ label: plan.name!, value: plan.id! })),
);
const resetCreateForm = () => { const resetCreateForm = () => {
createForm.email = ""; createForm.email = "";
createForm.username = ""; createForm.username = "";
@@ -140,10 +165,22 @@ const applyFilters = async () => {
await loadUsers(); 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; selectedRow.value = row;
selectedDetail.value = null;
actionError.value = null; actionError.value = null;
detailOpen.value = true; detailOpen.value = true;
try {
await loadUserDetail(row.id || '');
} catch (err: any) {
actionError.value = err?.message || 'Failed to load user details';
}
}; };
const openEditDialog = (row: AdminUserRow) => { const openEditDialog = (row: AdminUserRow) => {
@@ -172,6 +209,24 @@ const openDeleteDialog = (row: AdminUserRow) => {
deleteOpen.value = true; 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 () => { const submitCreate = async () => {
if (!canCreate.value) return; if (!canCreate.value) return;
submitting.value = true; submitting.value = true;
@@ -242,6 +297,7 @@ const submitDelete = async () => {
await rpcClient.deleteAdminUser({ id: selectedRow.value.id }); await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null; selectedRow.value = null;
selectedDetail.value = null;
if (page.value > 1 && rows.value.length === 1) page.value -= 1; if (page.value > 1 && rows.value.length === 1) page.value -= 1;
await loadUsers(); await loadUsers();
} catch (err: any) { } 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 () => { const previousPage = async () => {
if (page.value <= 1) return; if (page.value <= 1) return;
page.value -= 1; page.value -= 1;
@@ -305,7 +385,7 @@ const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
cell: ({ row }) => h( cell: ({ row }) => h(
"span", "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" row.original.role || "USER"
), ),
@@ -351,6 +431,7 @@ const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [ 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: "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: () => 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" }), h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }),
]), ]),
meta: { meta: {
@@ -365,6 +446,26 @@ watch(roleFilter, async () => {
await loadUsers(); 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); onMounted(loadUsers);
</script> </script>
@@ -372,29 +473,27 @@ onMounted(loadUsers);
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div <AdminMetricCard
v-for="item in summary" v-for="item in summary"
:key="item.label" :key="item.label"
class="rounded-lg border border-border bg-muted/20 p-4" :label="item.label"
> :value="item.value"
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> />
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
</div>
</template> </template>
<div class="space-y-4"> <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="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="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px] lg:min-w-[560px]">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.16em] text-foreground/50">Search</label> <label class="text-xs font-medium text-foreground/60">Search</label>
<AppInput v-model="search" placeholder="Search by email or username" @enter="applyFilters" /> <AdminInput v-model="search" placeholder="Search by email or username" @enter="applyFilters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.16em] text-foreground/50">Role filter</label> <label class="text-xs font-medium text-foreground/60">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"> <AdminSelect v-model="roleFilter">
<option v-for="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option> <option v-for="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option>
</select> </AdminSelect>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -402,21 +501,17 @@ onMounted(loadUsers);
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton> <AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton>
</div> </div>
</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"> <div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass=""> <AdminSectionCard 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>
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" /> <AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
<template v-else> <template v-else>
<BaseTable <AdminTable
:data="rows" :data="rows"
:columns="columns" :columns="columns"
:get-row-id="(row) => row.id || row.email || ''" :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> <p class="text-xs text-foreground/40">Try clearing the search term or switching the selected role.</p>
</div> </div>
</template> </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="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 Page {{ page }} of {{ totalPages }} · {{ total }} records
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -443,37 +538,23 @@ onMounted(loadUsers);
</div> </div>
</div> </div>
</template> </template>
</SettingsSectionCard> </AdminSectionCard>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-2xl" @close="actionError = null"> <AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-2xl" @close="actionError = null">
<div class="space-y-4"> <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 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"> <AdminUserFormFields
<div class="space-y-2 md:col-span-2"> mode="create"
<label class="text-sm font-medium text-gray-700">Email</label> v-model:email="createForm.email"
<AppInput v-model="createForm.email" placeholder="user@example.com" /> v-model:username="createForm.username"
</div> v-model:role="createForm.role"
<div class="space-y-2"> v-model:password="createForm.password"
<label class="text-sm font-medium text-gray-700">Username</label> v-model:plan-id="createForm.planId"
<AppInput v-model="createForm.username" placeholder="Optional" /> :role-options="roleOptions"
</div> :load-plan-options="planOptionsLoader"
<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>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@@ -492,7 +573,7 @@ onMounted(loadUsers);
<div class="grid gap-3"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
@@ -500,6 +581,7 @@ onMounted(loadUsers);
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton> <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> <AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
</div> </div>
</template> </template>
@@ -508,34 +590,16 @@ onMounted(loadUsers);
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-2xl" @close="actionError = null"> <AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-2xl" @close="actionError = null">
<div class="space-y-4"> <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 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"> <AdminUserFormFields
<div class="space-y-2 md:col-span-2"> mode="edit"
<label class="text-sm font-medium text-gray-700">Email</label> v-model:email="editForm.email"
<AppInput v-model="editForm.email" placeholder="user@example.com" /> v-model:username="editForm.username"
</div> v-model:role="editForm.role"
<div class="space-y-2"> v-model:password="editForm.password"
<label class="text-sm font-medium text-gray-700">Username</label> v-model:plan-id="editForm.planId"
<AppInput v-model="editForm.username" placeholder="Optional" /> :role-options="roleOptions"
</div> :load-plan-options="planOptionsLoader"
<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>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@@ -549,10 +613,10 @@ onMounted(loadUsers);
<div class="space-y-4"> <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 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"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="roleForm.role">
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option> <option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
</select> </AdminSelect>
</div> </div>
</div> </div>
<template #footer> <template #footer>
@@ -563,10 +627,42 @@ onMounted(loadUsers);
</template> </template>
</AppDialog> </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"> <AppDialog v-model:visible="deleteOpen" title="Delete user" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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. Delete <span class="font-medium">{{ selectedRow?.email || selectedRow?.id }}</span> and related data.
</p> </p>
</div> </div>

View File

@@ -2,11 +2,14 @@
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/ui/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AdminInput from "./components/AdminInput.vue";
import BaseTable from "@/components/ui/BaseTable.vue"; import AdminSelect from "./components/AdminSelect.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.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 { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue"; import { computed, h, onMounted, reactive, ref, watch } from "vue";
import AdminMetricCard from "./components/AdminMetricCard.vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
@@ -319,7 +322,7 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
header: "Status", header: "Status",
accessorFn: row => row.status || "", accessorFn: row => row.status || "",
cell: ({ row }) => h("span", { 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"), }, row.original.status || "UNKNOWN"),
meta: { meta: {
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", 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(() => ({ useAdminPageHeader(() => ({
// eyebrow: "Media", eyebrow: 'Media',
// badge: `${total.value} total videos`, badge: loading.value ? 'Syncing media inventory' : `${total.value} total videos`,
// actions: [ actions: [
// { {
// label: "Refresh", label: 'Refresh',
// variant: "secondary", variant: 'secondary',
// onClick: loadVideos, loading: loading.value,
// }, onClick: loadVideos,
// { },
// label: "Create video", {
// onClick: () => { label: 'Create video',
// actionError.value = null; onClick: () => {
// createOpen.value = true; actionError.value = null;
// }, createOpen.value = true;
// }, },
// ], },
// })); ],
}));
watch(statusFilter, async () => { watch(statusFilter, async () => {
page.value = 1; page.value = 1;
@@ -402,48 +406,46 @@ onMounted(loadVideos);
<AdminSectionShell> <AdminSectionShell>
<template #stats> <template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4"> <AdminMetricCard
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div> v-for="item in summary"
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div> :key="item.label"
</div> :label="item.label"
:value="item.value"
/>
</template> </template>
<div class="space-y-4"> <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="grid gap-3 xl:grid-cols-[minmax(0,1fr)_220px_180px_auto] xl:items-end">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label> <label class="text-xs font-medium text-foreground/60">Search</label>
<AppInput v-model="search" placeholder="Search by title" @enter="applyFilters" /> <AdminInput v-model="search" placeholder="Search by title" @enter="applyFilters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Owner reference</label> <label class="text-xs font-medium text-foreground/60">Owner reference</label>
<AppInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" /> <AdminInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Status</label> <label class="text-xs font-medium text-foreground/60">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"> <AdminSelect v-model="statusFilter">
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option> <option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
</select> </AdminSelect>
</div> </div>
<div class="flex items-center gap-2 xl:justify-end"> <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="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; statusFilter = ''; loadVideos()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton> <AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div> </div>
</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"> <div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass=""> <AdminSectionCard 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>
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" /> <AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable <AdminTable
v-else v-else
:data="rows" :data="rows"
:columns="columns" :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> <p class="text-xs text-foreground/40">Try a broader title or clear the owner and status filters.</p>
</div> </div>
</template> </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="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"> <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 <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton> <AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div> </div>
</div> </div>
</SettingsSectionCard> </AdminSectionCard>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
@@ -480,12 +482,12 @@ onMounted(loadVideos);
</div> </div>
<div class="grid gap-3"> <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 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 class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
</div> </div>
</div> </div>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-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">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 class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.url }}</div>
</div> </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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label> <label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" /> <AdminInput v-model="createForm.userId" placeholder="user-id" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="createForm.status">
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option> <option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Title</label> <label class="text-sm font-medium text-foreground/70">Title</label>
<AppInput v-model="createForm.title" placeholder="Video title" /> <AdminInput v-model="createForm.title" placeholder="Video title" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Video URL</label> <label class="text-sm font-medium text-foreground/70">Video URL</label>
<AppInput v-model="createForm.url" placeholder="https://..." /> <AdminInput v-model="createForm.url" placeholder="https://..." />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Format</label> <label class="text-sm font-medium text-foreground/70">Format</label>
<AppInput v-model="createForm.format" placeholder="mp4" /> <AdminInput v-model="createForm.format" placeholder="mp4" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad template ID</label> <label class="text-sm font-medium text-foreground/70">Ad template ID</label>
<AppInput v-model="createForm.adTemplateId" placeholder="Optional" /> <AdminInput v-model="createForm.adTemplateId" placeholder="Optional" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Size</label> <label class="text-sm font-medium text-foreground/70">Size</label>
<AppInput v-model="createForm.size" type="number" placeholder="0" min="0" /> <AdminInput v-model="createForm.size" type="number" placeholder="0" min="0" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label> <label class="text-sm font-medium text-foreground/70">Duration</label>
<AppInput v-model="createForm.duration" type="number" placeholder="0" min="0" /> <AdminInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
</div> </div>
</div> </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 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="grid gap-4 md:grid-cols-2">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label> <label class="text-sm font-medium text-foreground/70">Owner user ID</label>
<AppInput v-model="editForm.userId" placeholder="user-id" /> <AdminInput v-model="editForm.userId" placeholder="user-id" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label> <label class="text-sm font-medium text-foreground/70">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"> <AdminSelect v-model="editForm.status">
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option> <option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
</select> </AdminSelect>
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Title</label> <label class="text-sm font-medium text-foreground/70">Title</label>
<AppInput v-model="editForm.title" placeholder="Video title" /> <AdminInput v-model="editForm.title" placeholder="Video title" />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Video URL</label> <label class="text-sm font-medium text-foreground/70">Video URL</label>
<AppInput v-model="editForm.url" placeholder="https://..." /> <AdminInput v-model="editForm.url" placeholder="https://..." />
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label> <label class="text-sm font-medium text-foreground/70">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" /> <AdminTextarea v-model="editForm.description" rows="3" placeholder="Optional" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Format</label> <label class="text-sm font-medium text-foreground/70">Format</label>
<AppInput v-model="editForm.format" placeholder="mp4" /> <AdminInput v-model="editForm.format" placeholder="mp4" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad template ID</label> <label class="text-sm font-medium text-foreground/70">Ad template ID</label>
<AppInput v-model="editForm.adTemplateId" placeholder="Optional" /> <AdminInput v-model="editForm.adTemplateId" placeholder="Optional" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Size</label> <label class="text-sm font-medium text-foreground/70">Size</label>
<AppInput v-model="editForm.size" type="number" placeholder="0" min="0" /> <AdminInput v-model="editForm.size" type="number" placeholder="0" min="0" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label> <label class="text-sm font-medium text-foreground/70">Duration</label>
<AppInput v-model="editForm.duration" type="number" placeholder="0" min="0" /> <AdminInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
</div> </div>
</div> </div>
</div> </div>
@@ -605,7 +607,7 @@ onMounted(loadVideos);
<AppDialog v-model:visible="deleteOpen" title="Delete video" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="deleteOpen" title="Delete video" maxWidthClass="max-w-md" @close="actionError = null">
<div class="space-y-4"> <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 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>. Delete video <span class="font-medium">{{ selectedRow?.title || 'this video' }}</span>.
</p> </p>
</div> </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"> <script setup lang="ts">
import BaseTable from '@/components/ui/BaseTable.vue';
import type { ColumnDef } from '@tanstack/vue-table'; import type { ColumnDef } from '@tanstack/vue-table';
import { computed, h } from 'vue'; import { computed, h } from 'vue';
import AdminTable from './AdminTable.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
columns?: number | string[]; columns?: number | string[];
@@ -42,12 +42,10 @@ const tableColumns = computed<ColumnDef<SkeletonRow>[]>(() =>
</script> </script>
<template> <template>
<BaseTable <AdminTable
:data="data" :data="data"
:columns="tableColumns" :columns="tableColumns"
wrapperClass="border-0 rounded-none bg-transparent" wrapperClass="border-0 rounded-none bg-transparent"
tableClass="w-full"
headerRowClass="bg-muted/30"
bodyRowClass="animate-pulse border-b border-border hover:bg-transparent" bodyRowClass="animate-pulse border-b border-border hover:bg-transparent"
/> />
</template> </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 = { export type AdminHeaderAction = {
label: string; label: string;
icon?: string | VNode; icon?: string | VNode;
variant?: 'primary' | 'secondary' | 'danger'; variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
onClick: () => void; onClick: () => void;
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ export interface RegisterRequest {
username?: string | undefined; username?: string | undefined;
email?: string | undefined; email?: string | undefined;
password?: string | undefined; password?: string | undefined;
refUsername?: string | undefined;
} }
export interface RegisterResponse { export interface RegisterResponse {
@@ -67,6 +68,7 @@ export interface GetGoogleLoginUrlResponse {
export interface CompleteGoogleLoginRequest { export interface CompleteGoogleLoginRequest {
code?: string | undefined; code?: string | undefined;
refUsername?: string | undefined;
} }
export interface CompleteGoogleLoginResponse { export interface CompleteGoogleLoginResponse {
@@ -208,7 +210,7 @@ export const LoginResponse: MessageFns<LoginResponse> = {
}; };
function createBaseRegisterRequest(): RegisterRequest { function createBaseRegisterRequest(): RegisterRequest {
return { username: "", email: "", password: "" }; return { username: "", email: "", password: "", refUsername: undefined };
} }
export const RegisterRequest: MessageFns<RegisterRequest> = { export const RegisterRequest: MessageFns<RegisterRequest> = {
@@ -222,6 +224,9 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
if (message.password !== undefined && message.password !== "") { if (message.password !== undefined && message.password !== "") {
writer.uint32(26).string(message.password); writer.uint32(26).string(message.password);
} }
if (message.refUsername !== undefined) {
writer.uint32(34).string(message.refUsername);
}
return writer; return writer;
}, },
@@ -256,6 +261,14 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
message.password = reader.string(); message.password = reader.string();
continue; continue;
} }
case 4: {
if (tag !== 34) {
break;
}
message.refUsername = reader.string();
continue;
}
} }
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@@ -270,6 +283,11 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
username: isSet(object.username) ? globalThis.String(object.username) : "", username: isSet(object.username) ? globalThis.String(object.username) : "",
email: isSet(object.email) ? globalThis.String(object.email) : "", email: isSet(object.email) ? globalThis.String(object.email) : "",
password: isSet(object.password) ? globalThis.String(object.password) : "", 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 !== "") { if (message.password !== undefined && message.password !== "") {
obj.password = message.password; obj.password = message.password;
} }
if (message.refUsername !== undefined) {
obj.refUsername = message.refUsername;
}
return obj; return obj;
}, },
@@ -295,6 +316,7 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
message.username = object.username ?? ""; message.username = object.username ?? "";
message.email = object.email ?? ""; message.email = object.email ?? "";
message.password = object.password ?? ""; message.password = object.password ?? "";
message.refUsername = object.refUsername ?? undefined;
return message; return message;
}, },
}; };
@@ -724,7 +746,7 @@ export const GetGoogleLoginUrlResponse: MessageFns<GetGoogleLoginUrlResponse> =
}; };
function createBaseCompleteGoogleLoginRequest(): CompleteGoogleLoginRequest { function createBaseCompleteGoogleLoginRequest(): CompleteGoogleLoginRequest {
return { code: "" }; return { code: "", refUsername: undefined };
} }
export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest> = { export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest> = {
@@ -732,6 +754,9 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
if (message.code !== undefined && message.code !== "") { if (message.code !== undefined && message.code !== "") {
writer.uint32(10).string(message.code); writer.uint32(10).string(message.code);
} }
if (message.refUsername !== undefined) {
writer.uint32(18).string(message.refUsername);
}
return writer; return writer;
}, },
@@ -750,6 +775,14 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
message.code = reader.string(); message.code = reader.string();
continue; continue;
} }
case 2: {
if (tag !== 18) {
break;
}
message.refUsername = reader.string();
continue;
}
} }
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@@ -760,7 +793,14 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
}, },
fromJSON(object: any): 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 { toJSON(message: CompleteGoogleLoginRequest): unknown {
@@ -768,6 +808,9 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
if (message.code !== undefined && message.code !== "") { if (message.code !== undefined && message.code !== "") {
obj.code = message.code; obj.code = message.code;
} }
if (message.refUsername !== undefined) {
obj.refUsername = message.refUsername;
}
return obj; return obj;
}, },
@@ -777,6 +820,7 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
fromPartial<I extends Exact<DeepPartial<CompleteGoogleLoginRequest>, I>>(object: I): CompleteGoogleLoginRequest { fromPartial<I extends Exact<DeepPartial<CompleteGoogleLoginRequest>, I>>(object: I): CompleteGoogleLoginRequest {
const message = createBaseCompleteGoogleLoginRequest(); const message = createBaseCompleteGoogleLoginRequest();
message.code = object.code ?? ""; message.code = object.code ?? "";
message.refUsername = object.refUsername ?? undefined;
return message; 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 { Context, Hono } from "hono";
import { deleteCookie } from "hono/cookie"; import { deleteCookie, getCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { getAuthServiceClient, getInternalGrpcMetadata } from "../services/grpcClient"; import { getAuthServiceClient, getInternalGrpcMetadata } from "../services/grpcClient";
import type { User } from "@/server/gen/proto/app/v1/common"; 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) => { authRoute.get("/google/callback", async (c) => {
const referralCookieName = "ref_username";
const oauthError = c.req.query("error")?.trim(); const oauthError = c.req.query("error")?.trim();
if (oauthError) { if (oauthError) {
return redirectToGoogleFinalize(c, "error", oauthError); return redirectToGoogleFinalize(c, "error", oauthError);
@@ -96,8 +97,9 @@ authRoute.get("/google/callback", async (c) => {
try { try {
const grpcCookies: string[] = []; const grpcCookies: string[] = [];
const refUsername = getCookie(c, referralCookieName)?.trim();
await authService().completeGoogleLogin( await authService().completeGoogleLogin(
{ code }, { code, refUsername: refUsername || undefined },
getInternalGrpcMetadata(), getInternalGrpcMetadata(),
{ {
onMetadata: (metadata) => { onMetadata: (metadata) => {
@@ -109,9 +111,11 @@ authRoute.get("/google/callback", async (c) => {
}, },
}, },
); );
deleteCookie(c, referralCookieName, { path: "/" });
forwardGrpcCookies(c, grpcCookies); forwardGrpcCookies(c, grpcCookies);
return redirectToGoogleFinalize(c, "success"); return redirectToGoogleFinalize(c, "success");
} catch (error) { } catch (error) {
deleteCookie(c, referralCookieName, { path: "/" });
const reason = normalizeGoogleAuthReason(error instanceof Error ? error.message : undefined); const reason = normalizeGoogleAuthReason(error instanceof Error ? error.message : undefined);
return redirectToGoogleFinalize(c, "error", reason); return redirectToGoogleFinalize(c, "error", reason);
} }

View File

@@ -53,6 +53,31 @@ export const adminMethods = {
const metadata = context.get("grpcMetadata"); const metadata = context.get("grpcMetadata");
return await adminClient.updateAdminUser(data, metadata); 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( updateAdminUserRole: validateFn(
z.object({ z.object({
id: z.string().trim().min(1), id: z.string().trim().min(1),
@@ -282,8 +307,80 @@ export const adminMethods = {
const metadata = context.get("grpcMetadata"); const metadata = context.get("grpcMetadata");
return await adminClient.deleteAdminAdTemplate(data, metadata); 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( listAdminJobs: validateFn(
z.object({ z.object({
cursor: optionalTrimmed(),
pageSize: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(), offset: z.number().int().min(0).optional(),
limit: z.number().int().min(1).max(100).optional(), limit: z.number().int().min(1).max(100).optional(),
agentId: optionalTrimmed(), agentId: optionalTrimmed(),

View File

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

View File

@@ -139,6 +139,67 @@ export const meMethods = {
const metadata = context.get("grpcMetadata"); const metadata = context.get("grpcMetadata");
return await adTemplatesClient.deleteAdTemplate(data, metadata); 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 () => { getPreferences: async () => {
const context = getContext(); const context = getContext();
const preferencesClient = context.get("preferencesServiceClient"); const preferencesClient = context.get("preferencesServiceClient");
@@ -151,13 +212,8 @@ export const meMethods = {
pushNotifications: z.boolean().optional(), pushNotifications: z.boolean().optional(),
marketingNotifications: z.boolean().optional(), marketingNotifications: z.boolean().optional(),
telegramNotifications: z.boolean().optional(), telegramNotifications: z.boolean().optional(),
autoplay: z.boolean().optional(), language: z.string().optional(),
loop: z.boolean().optional(), locale: z.string().optional(),
muted: z.boolean().optional(),
showControls: z.boolean().optional(),
pip: z.boolean().optional(),
airplay: z.boolean().optional(),
chromecast: z.boolean().optional(),
}), }),
)(async (data) => { )(async (data) => {
const context = getContext(); const context = getContext();

View File

@@ -19,9 +19,11 @@ import {
import { import {
AdTemplatesServiceClient, AdTemplatesServiceClient,
DomainsServiceClient, DomainsServiceClient,
PlayerConfigsServiceClient,
PlansServiceClient, PlansServiceClient,
type AdTemplatesServiceClient as AdTemplatesServiceClientType, type AdTemplatesServiceClient as AdTemplatesServiceClientType,
type DomainsServiceClient as DomainsServiceClientType, type DomainsServiceClient as DomainsServiceClientType,
type PlayerConfigsServiceClient as PlayerConfigsServiceClientType,
type PlansServiceClient as PlansServiceClientType, type PlansServiceClient as PlansServiceClientType,
} from "@/server/gen/proto/app/v1/catalog"; } from "@/server/gen/proto/app/v1/catalog";
import { import {
@@ -45,6 +47,7 @@ declare module "hono" {
adTemplatesServiceClient: PromisifiedClient<AdTemplatesServiceClientType>; adTemplatesServiceClient: PromisifiedClient<AdTemplatesServiceClientType>;
videosServiceClient: PromisifiedClient<VideosServiceClientType>; videosServiceClient: PromisifiedClient<VideosServiceClientType>;
domainsServiceClient: PromisifiedClient<DomainsServiceClientType>; domainsServiceClient: PromisifiedClient<DomainsServiceClientType>;
playerConfigsServiceClient: PromisifiedClient<PlayerConfigsServiceClientType>;
plansServiceClient: PromisifiedClient<PlansServiceClientType>; plansServiceClient: PromisifiedClient<PlansServiceClientType>;
paymentsServiceClient: PromisifiedClient<PaymentsServiceClientType>; paymentsServiceClient: PromisifiedClient<PaymentsServiceClientType>;
preferencesServiceClient: PromisifiedClient<PreferencesServiceClientType>; preferencesServiceClient: PromisifiedClient<PreferencesServiceClientType>;
@@ -164,6 +167,14 @@ export const getDomainsServiceClient = () => {
return context.get("domainsServiceClient"); 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 = () => { export const getPlansServiceClient = () => {
const context = tryGetContext(); const context = tryGetContext();
if (!context) { if (!context) {
@@ -218,6 +229,7 @@ export const setupServices = (app: Hono) => {
const adTemplatesClient = new AdTemplatesServiceClient(grpcAddress(), creds); const adTemplatesClient = new AdTemplatesServiceClient(grpcAddress(), creds);
const videosClient = new VideosServiceClient(grpcAddress(), creds); const videosClient = new VideosServiceClient(grpcAddress(), creds);
const domainsClient = new DomainsServiceClient(grpcAddress(), creds); const domainsClient = new DomainsServiceClient(grpcAddress(), creds);
const playerConfigsClient = new PlayerConfigsServiceClient(grpcAddress(), creds);
const plansClient = new PlansServiceClient(grpcAddress(), creds); const plansClient = new PlansServiceClient(grpcAddress(), creds);
const paymentsClient = new PaymentsServiceClient(grpcAddress(), creds); const paymentsClient = new PaymentsServiceClient(grpcAddress(), creds);
const preferencesClient = new PreferencesServiceClient(grpcAddress(), creds); const preferencesClient = new PreferencesServiceClient(grpcAddress(), creds);
@@ -230,6 +242,7 @@ export const setupServices = (app: Hono) => {
c.set("adTemplatesServiceClient", promisifyClient(adTemplatesClient)); c.set("adTemplatesServiceClient", promisifyClient(adTemplatesClient));
c.set("videosServiceClient", promisifyClient(videosClient)); c.set("videosServiceClient", promisifyClient(videosClient));
c.set("domainsServiceClient", promisifyClient(domainsClient)); c.set("domainsServiceClient", promisifyClient(domainsClient));
c.set("playerConfigsServiceClient", promisifyClient(playerConfigsClient));
c.set("plansServiceClient", promisifyClient(plansClient)); c.set("plansServiceClient", promisifyClient(plansClient));
c.set("paymentsServiceClient", promisifyClient(paymentsClient)); c.set("paymentsServiceClient", promisifyClient(paymentsClient));
c.set("preferencesServiceClient", promisifyClient(preferencesClient)); 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 (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(); const response = await rpcClient.getGoogleLoginUrl();
if (!response.url) { if (!response.url) {
throw new Error(t("auth.errors.unknown")); throw new Error(t("auth.errors.unknown"));
@@ -130,12 +135,12 @@ export const useAuthStore = defineStore("auth", () => {
window.location.assign(response.url); 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; loading.value = true;
error.value = null; error.value = null;
try { try {
await rpcClient.register({ username, email, password }); await rpcClient.register({ username, email, password, refUsername: refUsername?.trim() || undefined });
await router.push("/login"); await router.push("/login");
} catch (e: any) { } catch (e: any) {
error.value = t("auth.errors.registrationFailed", { error.value = t("auth.errors.registrationFailed", {

View File

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