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:
3
bun.lock
3
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@hattip/adapter-node": "^0.0.49",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^1.0.0",
|
||||
@@ -176,6 +177,8 @@
|
||||
|
||||
"@hiogawa/tiny-rpc": ["@hiogawa/tiny-rpc@0.2.3-pre.18", "", {}, "sha512-BiNHrutG9G9yV622QvkxZxF+PhkaH2Aspp4/X1KYTfnaQTcg4fFUTBWf5Kf533swon2SuVJwi6U6H1LQbhVOQQ=="],
|
||||
|
||||
"@hiogawa/utils": ["@hiogawa/utils@1.7.0", "", {}, "sha512-ghiEFWBR1NENoHn+lSuW7liicTIzVPN+8Srm5UedCTw43gus0mlse6Wp2lz6GmbOXJ/CalMPp/0Tz2X8tajkAg=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
|
||||
|
||||
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -61,6 +61,7 @@ declare module 'vue' {
|
||||
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
@@ -80,7 +81,6 @@ declare module 'vue' {
|
||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
User2: typeof import('./src/components/icons/User2.vue')['default']
|
||||
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||
'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
@@ -146,6 +146,7 @@ declare global {
|
||||
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
const OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
@@ -165,7 +166,6 @@ declare global {
|
||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
const User2: typeof import('./src/components/icons/User2.vue')['default']
|
||||
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||
const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@hattip/adapter-node": "^0.0.49",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^1.0.0",
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"security": "Security",
|
||||
"billing": "Billing & Plans",
|
||||
"notifications": "Notifications",
|
||||
"player": "Player",
|
||||
"playerGroup": "Player",
|
||||
"playerConfigs": "Player Configs",
|
||||
"domains": "Allowed Domains",
|
||||
"ads": "Ads & VAST",
|
||||
"danger": "Danger Zone"
|
||||
@@ -128,9 +129,9 @@
|
||||
"title": "Notifications",
|
||||
"subtitle": "Choose how you want to receive notifications and updates."
|
||||
},
|
||||
"player": {
|
||||
"title": "Player Settings",
|
||||
"subtitle": "Configure default video player behavior and features."
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"subtitle": "Manage your account preferences and notification channels."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Billing & Plans",
|
||||
@@ -144,6 +145,10 @@
|
||||
"title": "Ads & VAST",
|
||||
"subtitle": "Create and manage VAST ad templates for your videos."
|
||||
},
|
||||
"playerConfigs": {
|
||||
"title": "Player Configs",
|
||||
"subtitle": "Create and manage player configurations for your videos."
|
||||
},
|
||||
"danger": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Irreversible and destructive actions. Be careful!"
|
||||
@@ -293,6 +298,126 @@
|
||||
"failedDetail": "Failed to load or update domains."
|
||||
}
|
||||
},
|
||||
"playerConfigs": {
|
||||
"createConfig": "Create Config",
|
||||
"infoBanner": "Player configs let you customize playback behavior such as autoplay, loop, controls, and casting features.",
|
||||
"freePlanTitle": "Free plan limit",
|
||||
"freePlanMessage": "Free accounts can create and manage 1 player config. After you create one, create is disabled until you delete it.",
|
||||
"reconciliationTitle": "Too many configs for free plan",
|
||||
"reconciliationMessage": "Your account still has more than 1 player config from a previous paid plan. Delete extra configs until only 1 remains to edit, enable, or set a default again.",
|
||||
"readOnlyTitle": "Free plan limit",
|
||||
"readOnlyMessage": "Free accounts can manage 1 player config. Delete extra configs after downgrade to continue editing.",
|
||||
"defaultBadge": "Default",
|
||||
"createdOn": "Created {{date}}",
|
||||
"emptyTitle": "No player configs yet",
|
||||
"emptySubtitle": "Create your first config to customize video playback",
|
||||
"items": {
|
||||
"autoplay": {
|
||||
"title": "Autoplay",
|
||||
"description": "Automatically start videos when loaded"
|
||||
},
|
||||
"loop": {
|
||||
"title": "Loop",
|
||||
"description": "Repeat video when it ends"
|
||||
},
|
||||
"muted": {
|
||||
"title": "Muted",
|
||||
"description": "Start videos with sound muted"
|
||||
},
|
||||
"showControls": {
|
||||
"title": "Show Controls",
|
||||
"description": "Display player controls during playback"
|
||||
},
|
||||
"pip": {
|
||||
"title": "Picture in Picture",
|
||||
"description": "Enable Picture-in-Picture mode"
|
||||
},
|
||||
"airplay": {
|
||||
"title": "AirPlay",
|
||||
"description": "Allow streaming to Apple devices via AirPlay"
|
||||
},
|
||||
"chromecast": {
|
||||
"title": "Chromecast",
|
||||
"description": "Allow casting to Chromecast devices"
|
||||
},
|
||||
"encrytionM3u8": {
|
||||
"title": "HLS Encryption (m3u8)",
|
||||
"description": "Enable encryption for HLS streams."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"autoplay": "Autoplay",
|
||||
"loop": "Loop",
|
||||
"muted": "Muted",
|
||||
"controls": "Controls",
|
||||
"pip": "PiP",
|
||||
"airplay": "AirPlay",
|
||||
"chromecast": "Chromecast",
|
||||
"encrytionM3u8": "Encrypted HLS",
|
||||
"logo": "Logo"
|
||||
},
|
||||
"state": {
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled"
|
||||
},
|
||||
"actions": {
|
||||
"default": "Default",
|
||||
"setDefault": "Set Default"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"dialog": {
|
||||
"editTitle": "Edit Config",
|
||||
"createTitle": "Create Player Config",
|
||||
"name": "Config Name",
|
||||
"namePlaceholder": "e.g., Mobile Player, Desktop Player",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Brief description for this config",
|
||||
"playbackOptions": "Playback Options",
|
||||
"castingOptions": "Casting Options",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"logoUrl": "Logo URL",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoUrlHint": "Optional logo image shown in the player overlay.",
|
||||
"defaultLabel": "Default Config",
|
||||
"defaultCheckbox": "Use this config as default for new videos",
|
||||
"defaultHint": "When enabled, newly created videos will automatically use this active config.",
|
||||
"defaultDisabledHint": "Please enable this config before setting it as default.",
|
||||
"update": "Update",
|
||||
"create": "Create"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteMessage": "Are you sure you want to delete \"{name}\"?",
|
||||
"deleteHeader": "Delete Config",
|
||||
"deleteAccept": "Delete",
|
||||
"deleteReject": "Cancel"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequiredSummary": "Name required",
|
||||
"nameRequiredDetail": "Please enter a config name.",
|
||||
"updatedSummary": "Config updated",
|
||||
"updatedDetail": "Player config has been updated.",
|
||||
"createdSummary": "Config created",
|
||||
"createdDetail": "Player config has been created.",
|
||||
"enabledSummary": "Config enabled",
|
||||
"disabledSummary": "Config disabled",
|
||||
"defaultUpdatedSummary": "Default updated",
|
||||
"defaultUpdatedDetail": "{name} is now the default config for new videos.",
|
||||
"upgradeRequiredSummary": "Config limit reached",
|
||||
"upgradeRequiredDetail": "Free accounts can only have 1 player config.",
|
||||
"limitSummary": "Config limit reached",
|
||||
"limitDetail": "Free accounts can only have 1 player config.",
|
||||
"reconciliationSummary": "Delete extra configs",
|
||||
"reconciliationDetail": "Delete extra player configs until only 1 remains to continue managing them on the free plan.",
|
||||
"toggleDetail": "{name} has been {state}.",
|
||||
"deletedSummary": "Config deleted",
|
||||
"deletedDetail": "Player config has been removed.",
|
||||
"failedSummary": "Action failed",
|
||||
"failedDetail": "Failed to load or update player configs."
|
||||
}
|
||||
},
|
||||
"adsVast": {
|
||||
"createTemplate": "Create Template",
|
||||
"infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.",
|
||||
@@ -629,6 +754,13 @@
|
||||
"toast": {
|
||||
"dismissAria": "Dismiss"
|
||||
},
|
||||
"network": {
|
||||
"offline": {
|
||||
"title": "You're offline",
|
||||
"description": "Your internet connection appears to be unavailable. Check your network and we'll reconnect automatically when you're back online.",
|
||||
"action": "Try again"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"welcome": {
|
||||
"title": "Hello, {{name}}",
|
||||
@@ -638,7 +770,27 @@
|
||||
"totalVideos": "Total Videos",
|
||||
"totalViews": "Total Views",
|
||||
"storageUsed": "Storage Used",
|
||||
"trendVsLastMonth": "vs last month"
|
||||
"trendVsLastMonth": "vs last month",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"admin-quickActions": {
|
||||
"title": "Admin Quick Actions",
|
||||
"manageUsers": {
|
||||
"title": "Manage Users",
|
||||
"description": "View and manage all user accounts"
|
||||
},
|
||||
"viewReports": {
|
||||
"title": "View Reports",
|
||||
"description": "Access detailed analytics and reports"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "System Settings",
|
||||
"description": "Configure system-wide settings and preferences"
|
||||
},
|
||||
"billingOverview": {
|
||||
"title": "Billing Overview",
|
||||
"description": "Monitor billing and subscription details"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Quick Actions",
|
||||
@@ -1008,7 +1160,7 @@
|
||||
"description": "Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer."
|
||||
},
|
||||
"live": {
|
||||
"title": "Live Streaming API",
|
||||
"title": "Streaming API",
|
||||
"description": "Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.",
|
||||
"status": "Live Status",
|
||||
"onAir": "On Air",
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"security": "Bảo mật",
|
||||
"billing": "Thanh toán & Gói",
|
||||
"notifications": "Thông báo",
|
||||
"player": "Trình phát",
|
||||
"playerGroup": "Trình phát",
|
||||
"playerConfigs": "Cấu hình trình phát",
|
||||
"domains": "Tên miền được phép",
|
||||
"ads": "Quảng cáo & VAST",
|
||||
"danger": "Vùng nguy hiểm"
|
||||
@@ -128,9 +129,9 @@
|
||||
"title": "Thông báo",
|
||||
"subtitle": "Chọn cách bạn muốn nhận thông báo và cập nhật."
|
||||
},
|
||||
"player": {
|
||||
"title": "Cài đặt trình phát",
|
||||
"subtitle": "Cấu hình hành vi và tính năng mặc định của trình phát video."
|
||||
"preferences": {
|
||||
"title": "Tùy chọn",
|
||||
"subtitle": "Quản lý các tùy chọn tài khoản và kênh thông báo của bạn."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Thanh toán & Gói",
|
||||
@@ -144,6 +145,10 @@
|
||||
"title": "Quảng cáo & VAST",
|
||||
"subtitle": "Tạo và quản lý mẫu quảng cáo VAST cho video."
|
||||
},
|
||||
"playerConfigs": {
|
||||
"title": "Cấu hình trình phát",
|
||||
"subtitle": "Tạo và quản lý cấu hình trình phát cho video."
|
||||
},
|
||||
"danger": {
|
||||
"title": "Vùng nguy hiểm",
|
||||
"subtitle": "Hành động không thể hoàn tác và có tính phá hủy. Hãy cẩn thận!"
|
||||
@@ -293,6 +298,126 @@
|
||||
"failedDetail": "Không thể tải hoặc cập nhật danh sách tên miền."
|
||||
}
|
||||
},
|
||||
"playerConfigs": {
|
||||
"createConfig": "Tạo cấu hình",
|
||||
"infoBanner": "Cấu hình trình phát cho phép tùy chỉnh hành vi phát video như tự động phát, lặp, hiển thị điều khiển và các tính năng casting.",
|
||||
"freePlanTitle": "Giới hạn gói free",
|
||||
"freePlanMessage": "Tài khoản free có thể tạo và quản lý 1 player config. Sau khi đã có 1 config, bạn cần xóa nó trước khi tạo config mới.",
|
||||
"reconciliationTitle": "Có quá nhiều config cho gói free",
|
||||
"reconciliationMessage": "Tài khoản của bạn vẫn còn hơn 1 player config từ gói paid trước đó. Hãy xóa bớt cho đến khi chỉ còn 1 config để có thể sửa, bật/tắt hoặc đặt mặc định trở lại.",
|
||||
"readOnlyTitle": "Giới hạn gói free",
|
||||
"readOnlyMessage": "Tài khoản free có thể quản lý 1 player config. Sau khi downgrade, hãy xóa bớt config dư để tiếp tục chỉnh sửa.",
|
||||
"defaultBadge": "Mặc định",
|
||||
"createdOn": "Tạo ngày {{date}}",
|
||||
"emptyTitle": "Chưa có cấu hình",
|
||||
"emptySubtitle": "Tạo config đầu tiên để tùy chỉnh trải nghiệm phát video",
|
||||
"items": {
|
||||
"autoplay": {
|
||||
"title": "Tự phát",
|
||||
"description": "Tự động phát video khi tải xong"
|
||||
},
|
||||
"loop": {
|
||||
"title": "Lặp lại",
|
||||
"description": "Phát lại video khi kết thúc"
|
||||
},
|
||||
"muted": {
|
||||
"title": "Tắt tiếng",
|
||||
"description": "Bắt đầu video với âm thanh tắt"
|
||||
},
|
||||
"showControls": {
|
||||
"title": "Hiển thị điều khiển",
|
||||
"description": "Hiển thị thanh điều khiển phát video"
|
||||
},
|
||||
"pip": {
|
||||
"title": "Picture in Picture",
|
||||
"description": "Bật chế độ Picture-in-Picture"
|
||||
},
|
||||
"airplay": {
|
||||
"title": "AirPlay",
|
||||
"description": "Cho phép phát tới thiết bị Apple qua AirPlay"
|
||||
},
|
||||
"chromecast": {
|
||||
"title": "Chromecast",
|
||||
"description": "Cho phép cast tới thiết bị Chromecast"
|
||||
},
|
||||
"encrytionM3u8": {
|
||||
"title": "Mã hóa HLS (m3u8)",
|
||||
"description": "Bật mã hóa cho luồng HLS."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"autoplay": "Tự phát",
|
||||
"loop": "Lặp",
|
||||
"muted": "Tắt tiếng",
|
||||
"controls": "Điều khiển",
|
||||
"pip": "PiP",
|
||||
"airplay": "AirPlay",
|
||||
"chromecast": "Chromecast",
|
||||
"encrytionM3u8": "HLS mã hóa",
|
||||
"logo": "Logo"
|
||||
},
|
||||
"state": {
|
||||
"enabled": "bật",
|
||||
"disabled": "tắt"
|
||||
},
|
||||
"actions": {
|
||||
"default": "Mặc định",
|
||||
"setDefault": "Đặt mặc định"
|
||||
},
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"dialog": {
|
||||
"editTitle": "Sửa cấu hình",
|
||||
"createTitle": "Tạo cấu hình trình phát",
|
||||
"name": "Tên cấu hình",
|
||||
"namePlaceholder": "ví dụ: Mobile Player, Desktop Player",
|
||||
"description": "Mô tả",
|
||||
"descriptionPlaceholder": "Mô tả ngắn cho cấu hình này",
|
||||
"playbackOptions": "Tùy chọn phát lại",
|
||||
"castingOptions": "Tùy chọn casting",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"logoUrl": "URL logo",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoUrlHint": "Logo tùy chọn hiển thị trong lớp phủ của trình phát.",
|
||||
"defaultLabel": "Cấu hình mặc định",
|
||||
"defaultCheckbox": "Dùng cấu hình này mặc định cho video mới",
|
||||
"defaultHint": "Khi bật, video mới tạo sẽ tự động dùng cấu hình đang active này.",
|
||||
"defaultDisabledHint": "Hãy bật cấu hình này trước khi đặt làm mặc định.",
|
||||
"update": "Cập nhật",
|
||||
"create": "Tạo"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteMessage": "Bạn có chắc muốn xóa \"{name}\"?",
|
||||
"deleteHeader": "Xóa cấu hình",
|
||||
"deleteAccept": "Xóa",
|
||||
"deleteReject": "Hủy"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequiredSummary": "Thiếu tên cấu hình",
|
||||
"nameRequiredDetail": "Vui lòng nhập tên cấu hình.",
|
||||
"updatedSummary": "Đã cập nhật cấu hình",
|
||||
"updatedDetail": "Cấu hình trình phát đã được cập nhật.",
|
||||
"createdSummary": "Đã tạo cấu hình",
|
||||
"createdDetail": "Cấu hình trình phát đã được tạo.",
|
||||
"enabledSummary": "Đã bật cấu hình",
|
||||
"disabledSummary": "Đã tắt cấu hình",
|
||||
"defaultUpdatedSummary": "Đã cập nhật mặc định",
|
||||
"defaultUpdatedDetail": "{name} hiện là cấu hình mặc định cho video mới.",
|
||||
"upgradeRequiredSummary": "Đã đạt giới hạn cấu hình",
|
||||
"upgradeRequiredDetail": "Tài khoản free chỉ có thể có 1 player config.",
|
||||
"limitSummary": "Đã đạt giới hạn cấu hình",
|
||||
"limitDetail": "Tài khoản free chỉ có thể có 1 player config.",
|
||||
"reconciliationSummary": "Hãy xóa bớt config",
|
||||
"reconciliationDetail": "Hãy xóa các player config dư cho đến khi chỉ còn 1 config để tiếp tục quản lý trên gói free.",
|
||||
"toggleDetail": "{name} đã được {state}.",
|
||||
"deletedSummary": "Đã xóa cấu hình",
|
||||
"deletedDetail": "Cấu hình trình phát đã được gỡ bỏ.",
|
||||
"failedSummary": "Thao tác thất bại",
|
||||
"failedDetail": "Không thể tải hoặc cập nhật cấu hình trình phát."
|
||||
}
|
||||
},
|
||||
"adsVast": {
|
||||
"createTemplate": "Tạo mẫu",
|
||||
"infoBanner": "VAST (Video Ad Serving Template) là schema XML dùng để phân phối ad tags cho trình phát video.",
|
||||
@@ -628,6 +753,13 @@
|
||||
"toast": {
|
||||
"dismissAria": "Đóng"
|
||||
},
|
||||
"network": {
|
||||
"offline": {
|
||||
"title": "Bạn đang ngoại tuyến",
|
||||
"description": "Có vẻ như kết nối internet đã bị ngắt. Hãy kiểm tra mạng, ứng dụng sẽ tự kết nối lại khi bạn có mạng trở lại.",
|
||||
"action": "Thử lại"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"welcome": {
|
||||
"title": "Xin chào, {{name}}",
|
||||
@@ -637,7 +769,23 @@
|
||||
"totalVideos": "Tổng số video",
|
||||
"totalViews": "Tổng lượt xem",
|
||||
"storageUsed": "Dung lượng đã dùng",
|
||||
"trendVsLastMonth": "so với tháng trước"
|
||||
"trendVsLastMonth": "so với tháng trước",
|
||||
"unlimited": "Không giới hạn"
|
||||
},
|
||||
"admin-quickActions": {
|
||||
"title": "Thao tác nhanh cho quản trị viên",
|
||||
"manageUsers": {
|
||||
"title": "Quản lý người dùng",
|
||||
"description": "Xem và quản lý tất cả người dùng"
|
||||
},
|
||||
"viewReports": {
|
||||
"title": "Xem báo cáo",
|
||||
"description": "Phân tích hiệu suất hệ thống và hoạt động của người dùng"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "Cài đặt hệ thống",
|
||||
"description": "Cấu hình cài đặt và tùy chọn của hệ thống"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Thao tác nhanh",
|
||||
@@ -1007,7 +1155,7 @@
|
||||
"description": "Nội dung được phân phối từ hơn 200 PoP trên toàn thế giới. Tự động chọn vùng để có độ trễ thấp nhất cho mọi người xem."
|
||||
},
|
||||
"live": {
|
||||
"title": "Live Streaming API",
|
||||
"title": "Streaming API",
|
||||
"description": "Mở rộng tới hàng triệu người xem đồng thời với độ trễ cực thấp. Hỗ trợ RTMP ingest và HLS playback sẵn có.",
|
||||
"status": "Trạng thái trực tiếp",
|
||||
"onAir": "Đang phát",
|
||||
|
||||
@@ -38,20 +38,6 @@ const links = computed<Record<string, any>>(() => {
|
||||
},
|
||||
{ href: "/settings", label: t("nav.settings"), icon: SettingsIcon, action: null, className },
|
||||
] as const;
|
||||
|
||||
if (isAdmin.value) {
|
||||
return [
|
||||
...baseLinks,
|
||||
{
|
||||
href: "/admin/overview",
|
||||
label: "Admin Console",
|
||||
icon: LayoutDashboard,
|
||||
action: null,
|
||||
className,
|
||||
} as const,
|
||||
];
|
||||
}
|
||||
|
||||
return baseLinks;
|
||||
});
|
||||
</script>
|
||||
|
||||
67
src/components/OfflineOverlay.vue
Normal file
67
src/components/OfflineOverlay.vue
Normal 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>
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<AppTopLoadingBar />
|
||||
<OfflineOverlay />
|
||||
</ClientOnly>
|
||||
<router-view/>
|
||||
<router-view />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import ClientOnly from '@/components/ClientOnly';
|
||||
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
|
||||
import OfflineOverlay from '@/components/OfflineOverlay.vue'
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Trend {
|
||||
isPositive: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
export interface StatProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string | VNode;
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<StatProps>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ const iconColors = {
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ $t(title) }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ value }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="#a6acb9"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="518" viewBox="-10 -244 500 518"><path d="M461-229c12 5 19 16 19 29v416c0 13-7 24-19 29-11 5-25 3-34-5l-47-41c-43-38-98-60-156-63v96c0 18-14 32-32 32h-32c-18 0-32-14-32-32v-96C57 136 0 79 0 8s57-128 128-128h85c61 0 121-23 167-63l47-41c9-8 23-10 34-5zM224 72c70 3 138 29 192 74v-276c-54 45-122 71-192 74V72z" fill="currentColor"/></svg>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -242 500 516"><path d="M448-194v404l-26-24c-50-47-114-75-182-81V-89c68-6 132-34 182-81l26-24zM240 137c60 6 116 31 160 72l34 32c5 4 12 7 19 7 15 0 27-12 27-27v-425c0-16-12-28-27-28-7 0-14 3-19 8l-34 31c-50 47-116 73-185 73h-87C57-120 0-63 0 8c0 60 41 110 96 124v84c0 27 22 48 48 48h48c27 0 48-21 48-48v-79zm-40-1h8v80c0 9-7 16-16 16h-48c-9 0-16-7-16-16v-80h72zm0-224h8v192h-80c-53 0-96-43-96-96s43-96 96-96h72z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="#a6acb9"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="currentColor"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#a6acb9"/><path d="M10 106h512v64H10zm0 0z" fill="currentColor"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 106h512v64H10zm0 0z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
|
||||
<path
|
||||
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
|
||||
fill="#a6acb9" />
|
||||
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
|
||||
<path
|
||||
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
|
||||
fill="currentColor" />
|
||||
fill="var(--colors-primary-DEFAULT)" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
|
||||
<path
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type UiButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: UiButtonVariant;
|
||||
size?: UiButtonSize;
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(),
|
||||
{
|
||||
@@ -15,10 +18,13 @@ const props = withDefaults(
|
||||
size: 'md',
|
||||
block: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
type: 'button',
|
||||
},
|
||||
);
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.loading);
|
||||
|
||||
const classes = computed(() => {
|
||||
const variants: Record<UiButtonVariant, string> = {
|
||||
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
|
||||
@@ -34,7 +40,7 @@ const classes = computed(() => {
|
||||
};
|
||||
|
||||
return [
|
||||
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
|
||||
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color,opacity] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
|
||||
variants[props.variant],
|
||||
sizes[props.size],
|
||||
props.block ? 'w-full' : '',
|
||||
@@ -43,7 +49,13 @@ const classes = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :disabled="disabled" :class="classes">
|
||||
<button :type="type" :disabled="isDisabled" :class="classes" :aria-busy="loading || undefined">
|
||||
<span
|
||||
v-if="loading"
|
||||
class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
// Định nghĩa cấu trúc dữ liệu
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
@@ -14,15 +13,14 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Vui lòng chọn...',
|
||||
disabled: false
|
||||
placeholder: 'Please select...',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// Sử dụng defineModel thay cho props/emits thủ công
|
||||
const modelValue = defineModel<string | number>();
|
||||
|
||||
const options = ref<SelectOption[]>([]);
|
||||
const loading = ref<boolean>(false);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -30,29 +28,24 @@ const fetchData = async () => {
|
||||
error.value = null;
|
||||
try {
|
||||
options.value = await props.loadOptions();
|
||||
} catch (err) {
|
||||
error.value = 'Lỗi kết nối';
|
||||
} catch {
|
||||
error.value = 'Failed to load options';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
|
||||
// Tự động load lại nếu hàm fetch thay đổi
|
||||
watch(() => props.loadOptions, fetchData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative w-full max-w-64">
|
||||
<div class="space-y-2">
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
v-model="modelValue"
|
||||
:disabled="loading || disabled"
|
||||
class="w-full appearance-none rounded-lg border border-gray-300 bg-white px-4 py-2 pr-10
|
||||
text-gray-700 outline-none transition-all
|
||||
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
|
||||
disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
class="w-full appearance-none rounded-md border border-border bg-header px-3 py-2 pr-10 text-sm text-foreground outline-none transition-all focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="" disabled>{{ placeholder }}</option>
|
||||
<option
|
||||
@@ -64,21 +57,19 @@ watch(() => props.loadOptions, fetchData);
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
|
||||
<div class="i-carbon-chevron-down text-lg" />
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-foreground/40">
|
||||
<div v-if="loading" class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent" />
|
||||
<div v-else class="i-carbon-chevron-down text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center text-blue-500">
|
||||
<div class="i-carbon-circle-dash animate-spin text-xl" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="error"
|
||||
type="button"
|
||||
@click="fetchData"
|
||||
class="text-xs text-red-500 underline hover:text-red-600 transition"
|
||||
class="text-xs font-medium text-danger transition hover:opacity-80"
|
||||
>
|
||||
Thử lại?
|
||||
{{ error }} · Retry
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
47
src/composables/useNetworkStatus.ts
Normal file
47
src/composables/useNetworkStatus.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,8 @@ export type SettingsPreferencesSnapshot = {
|
||||
pushNotifications: boolean;
|
||||
marketingNotifications: boolean;
|
||||
telegramNotifications: boolean;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
muted: boolean;
|
||||
showControls: boolean;
|
||||
pip: boolean;
|
||||
airplay: boolean;
|
||||
chromecast: boolean;
|
||||
language: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export type NotificationSettingsDraft = {
|
||||
@@ -26,17 +21,6 @@ export type NotificationSettingsDraft = {
|
||||
telegram: boolean;
|
||||
};
|
||||
|
||||
export type PlayerSettingsDraft = {
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
muted: boolean;
|
||||
showControls: boolean;
|
||||
pip: boolean;
|
||||
airplay: boolean;
|
||||
chromecast: boolean;
|
||||
encrytion_m3u8: boolean;
|
||||
};
|
||||
|
||||
type PreferencesResponse = {
|
||||
preferences?: Preferences;
|
||||
};
|
||||
@@ -46,13 +30,8 @@ const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
||||
pushNotifications: true,
|
||||
marketingNotifications: false,
|
||||
telegramNotifications: false,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
muted: false,
|
||||
showControls: true,
|
||||
pip: true,
|
||||
airplay: true,
|
||||
chromecast: true,
|
||||
language: 'en',
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
|
||||
@@ -63,13 +42,8 @@ const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreference
|
||||
pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
||||
marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
||||
telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
||||
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay,
|
||||
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop,
|
||||
muted: preferences?.muted ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.muted,
|
||||
showControls: preferences?.showControls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
|
||||
pip: preferences?.pip ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pip,
|
||||
airplay: preferences?.airplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.airplay,
|
||||
chromecast: preferences?.chromecast ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.chromecast,
|
||||
language: preferences?.language ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.language,
|
||||
locale: preferences?.locale ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.locale,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,19 +56,6 @@ export const createNotificationSettingsDraft = (
|
||||
telegram: snapshot.telegramNotifications,
|
||||
});
|
||||
|
||||
export const createPlayerSettingsDraft = (
|
||||
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
|
||||
): PlayerSettingsDraft => ({
|
||||
autoplay: snapshot.autoplay,
|
||||
loop: snapshot.loop,
|
||||
muted: snapshot.muted,
|
||||
showControls: snapshot.showControls,
|
||||
pip: snapshot.pip,
|
||||
airplay: snapshot.airplay,
|
||||
chromecast: snapshot.chromecast,
|
||||
encrytion_m3u8: snapshot.chromecast
|
||||
});
|
||||
|
||||
export const toNotificationPreferencesPayload = (
|
||||
draft: NotificationSettingsDraft,
|
||||
): UpdatePreferencesRequest => ({
|
||||
@@ -104,18 +65,6 @@ export const toNotificationPreferencesPayload = (
|
||||
telegramNotifications: draft.telegram,
|
||||
});
|
||||
|
||||
export const toPlayerPreferencesPayload = (
|
||||
draft: PlayerSettingsDraft,
|
||||
): UpdatePreferencesRequest => ({
|
||||
autoplay: draft.autoplay,
|
||||
loop: draft.loop,
|
||||
muted: draft.muted,
|
||||
showControls: draft.showControls,
|
||||
pip: draft.pip,
|
||||
airplay: draft.airplay,
|
||||
chromecast: draft.chromecast,
|
||||
});
|
||||
|
||||
export function useSettingsPreferencesQuery() {
|
||||
return useQuery({
|
||||
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
|
||||
|
||||
@@ -97,3 +97,7 @@ export const getStatusSeverity = (status: string = "") => {
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
export const isAdmin = (role: string = "") => {
|
||||
const r = String(role).toLowerCase();
|
||||
return r === "admin" || r === "superadmin";
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -37,8 +37,28 @@
|
||||
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="refUsername" class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700">
|
||||
Signing up with referral: <span class="font-medium">@{{ refUsername }}</span>
|
||||
</div>
|
||||
|
||||
<AppButton type="submit" class="w-full">{{ t('auth.signup.createAccount') }}</AppButton>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">{{ t('auth.login.google') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2" @click="signupWithGoogle">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</AppButton>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
{{ t('auth.signup.alreadyHave') }}
|
||||
<router-link to="/login"
|
||||
@@ -50,13 +70,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const showPassword = ref(false);
|
||||
const { t } = useTranslation();
|
||||
const refUsername = computed(() => String(route.query.ref || '').trim());
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -86,6 +109,10 @@ const onFormSubmit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
auth.register(form.name, form.email, form.password);
|
||||
auth.register(form.name, form.email, form.password, refUsername.value || undefined);
|
||||
};
|
||||
|
||||
const signupWithGoogle = () => {
|
||||
auth.loginWithGoogle(refUsername.value || undefined);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -97,7 +97,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
<div
|
||||
v-for="signal in signalItems"
|
||||
:key="signal.label"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-5 py-4"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{{ signal.label }}
|
||||
@@ -211,7 +211,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-8 -258 529 532" fill="none">
|
||||
<path
|
||||
@@ -228,7 +228,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-50 text-violet-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 570 570" fill="none">
|
||||
<path
|
||||
@@ -249,7 +249,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-10 -226 532 468" fill="none">
|
||||
<path
|
||||
@@ -267,7 +267,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 sm:p-8">
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
|
||||
|
||||
@@ -64,6 +64,11 @@ const routes: RouteData[] = [
|
||||
name: "signup",
|
||||
component: () => import("./auth/signup.vue"),
|
||||
},
|
||||
{
|
||||
path: "ref/:username",
|
||||
name: "referral-entry",
|
||||
beforeEnter: (to) => ({ name: "signup", query: { ref: String(to.params.username || "") } }),
|
||||
},
|
||||
{
|
||||
path: "forgot",
|
||||
name: "forgot",
|
||||
@@ -177,13 +182,7 @@ const routes: RouteData[] = [
|
||||
},
|
||||
{
|
||||
path: "player",
|
||||
name: "settings-player",
|
||||
component: () => import("./settings/PlayerSettings/PlayerSettings.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Player Settings - Holistream",
|
||||
},
|
||||
},
|
||||
redirect: { name: "settings-player-configs" },
|
||||
},
|
||||
{
|
||||
path: "domains",
|
||||
@@ -205,6 +204,16 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "player-configs",
|
||||
name: "settings-player-configs",
|
||||
component: () => import("./settings/PlayerConfigs/PlayerConfigs.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Player Configs - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "danger",
|
||||
name: "settings-danger",
|
||||
@@ -215,23 +224,22 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
component: () => import("./admin/Layout.vue"),
|
||||
meta: { requiresAdmin: true },
|
||||
redirect: { name: "admin-overview" },
|
||||
children: [
|
||||
{ path: "overview", name: "admin-overview", component: () => import("./admin/Overview.vue") },
|
||||
{ path: "users", name: "admin-users", component: () => import("./admin/Users.vue") },
|
||||
{ path: "videos", name: "admin-videos", component: () => import("./admin/Videos.vue") },
|
||||
{ path: "payments", name: "admin-payments", component: () => import("./admin/Payments.vue") },
|
||||
{ path: "plans", name: "admin-plans", component: () => import("./admin/Plans.vue") },
|
||||
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./admin/AdTemplates.vue") },
|
||||
{ path: "jobs", name: "admin-jobs", component: () => import("./admin/Jobs.vue") },
|
||||
{ path: "agents", name: "admin-agents", component: () => import("./admin/Agents.vue") },
|
||||
{ path: "logs", name: "admin-logs", component: () => import("./admin/Logs.vue") },
|
||||
{ path: "users", name: "admin-users", component: () => import("./settings/admin/Users.vue") },
|
||||
{ path: "videos", name: "admin-videos", component: () => import("./settings/admin/Videos.vue") },
|
||||
{ path: "payments", name: "admin-payments", component: () => import("./settings/admin/Payments.vue") },
|
||||
{ path: "plans", name: "admin-plans", component: () => import("./settings/admin/Plans.vue") },
|
||||
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./settings/admin/AdTemplates.vue") },
|
||||
{ path: "player-configs", name: "admin-player-configs", component: () => import("./settings/admin/PlayerConfigs.vue") },
|
||||
{ path: "jobs", name: "admin-jobs", component: () => import("./settings/admin/Jobs.vue") },
|
||||
{ path: "agents", name: "admin-agents", component: () => import("./settings/admin/Agents.vue") },
|
||||
{ path: "logs", name: "admin-logs", component: () => import("./settings/admin/Logs.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,17 +8,35 @@ import NameGradient from './components/NameGradient.vue';
|
||||
import QuickActions from './components/QuickActions.vue';
|
||||
import RecentVideos from './components/RecentVideos.vue';
|
||||
import StatsOverview from './components/StatsOverview.vue';
|
||||
|
||||
import type { StatProps } from '@/components/dashboard/StatsCard.vue';
|
||||
import { formatBytes, isAdmin } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
const AdminOverview = defineAsyncComponent(() => import('./components/AdminOverview.vue'));
|
||||
const {t} = useTranslation()
|
||||
const auth = useAuthStore();
|
||||
const recentVideosLoading = ref(true);
|
||||
const recentVideos = ref<ModelVideo[]>([]);
|
||||
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery();
|
||||
const { data: usageSnapshot, isPending: isUsagePending, refresh } = useUsageQuery();
|
||||
|
||||
const stats = computed(() => ({
|
||||
totalVideos: usageSnapshot.value?.totalVideos ?? 0,
|
||||
totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
|
||||
storageUsed: usageSnapshot.value?.totalStorage ?? 0,
|
||||
storageLimit: 10737418240,
|
||||
}));
|
||||
const stats = computed<StatProps[]>(() => [
|
||||
{
|
||||
title: 'overview.stats.totalVideos',
|
||||
value: usageSnapshot.value?.totalVideos ?? 0,
|
||||
trend: { value: 12, isPositive: true }
|
||||
},
|
||||
{
|
||||
title: 'overview.stats.totalViews',
|
||||
value: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
|
||||
trend: { value: 8, isPositive: true }
|
||||
},
|
||||
{
|
||||
title: 'overview.stats.storageUsed',
|
||||
value: `${formatBytes(usageSnapshot.value?.totalStorage ?? 0)} / ${t('overview.stats.unlimited')}`,
|
||||
color: 'warning',
|
||||
trend: { value: 5, isPositive: false }
|
||||
}
|
||||
]);
|
||||
const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
@@ -34,6 +52,7 @@ const fetchDashboardData = async () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
fetchDashboardData();
|
||||
});
|
||||
</script>
|
||||
@@ -44,12 +63,12 @@ onMounted(() => {
|
||||
{ label: $t('pageHeader.dashboard') }
|
||||
]" />
|
||||
|
||||
<AdminOverview v-if="isAdmin(auth.user?.role)" />
|
||||
<template v-else>
|
||||
<StatsOverview :loading="statsLoading" :stats="stats" />
|
||||
|
||||
<QuickActions :loading="recentVideosLoading" />
|
||||
|
||||
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
|
||||
|
||||
</template>
|
||||
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
72
src/routes/overview/components/AdminOverview.vue
Normal file
72
src/routes/overview/components/AdminOverview.vue
Normal 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>
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-1">
|
||||
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || t('app.name') }) }}</span>
|
||||
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || $t('app.name') }) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,12 @@ const auth = useAuthStore();
|
||||
const isCopied = ref(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const url = computed(() => `${location.origin}/ref/${auth.user?.username || ''}`);
|
||||
const url = computed(() => {
|
||||
if (typeof location === 'undefined') {
|
||||
return auth.user?.username ? `/ref/${auth.user.username}` : '';
|
||||
}
|
||||
return `${location.origin}/ref/${auth.user?.username || ''}`;
|
||||
});
|
||||
|
||||
const copyToClipboard = ($event: MouseEvent) => {
|
||||
if ($event.target instanceof HTMLInputElement) {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import StatsCard, { type StatProps } from '@/components/dashboard/StatsCard.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
stats: {
|
||||
totalVideos: number;
|
||||
totalViews: number;
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
};
|
||||
stats: StatProps[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -21,7 +15,7 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div v-for="i in 3" :key="i" class="bg-header rounded-xl border border-gray-200 p-6">
|
||||
<div v-for="i in stats.length" :key="i" class="bg-header rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
@@ -33,12 +27,6 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
||||
|
||||
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
|
||||
:trend="{ value: 8, isPositive: true }" />
|
||||
|
||||
<StatsCard :title="t('overview.stats.storageUsed')"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||
<StatsCard v-for="stat in stats" :key="stat.title" v-bind="stat"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,99 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import AdsVastDialog from './components/AdsVastDialog.vue';
|
||||
import AdsVastNotices from './components/AdsVastNotices.vue';
|
||||
import AdsVastTable from './components/AdsVastTable.tsx';
|
||||
import AdsVastToolbar from './components/AdsVastToolbar.vue';
|
||||
import type {
|
||||
AdTemplate,
|
||||
CreateAdTemplateRequest
|
||||
} from './types';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
interface VastTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
vastUrl: string;
|
||||
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type AdTemplateApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
vastTagUrl?: string;
|
||||
adFormat?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number | null;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||
const createInitialFormData = (): CreateAdTemplateRequest => ({
|
||||
name: '',
|
||||
description: '',
|
||||
vastTagUrl: '',
|
||||
adFormat: 'pre-roll',
|
||||
duration: undefined,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const editingTemplate = ref<VastTemplate | null>(null);
|
||||
const editingTemplate = ref<AdTemplate | null>(null);
|
||||
const saving = ref(false);
|
||||
const deletingId = ref<string | null>(null);
|
||||
const togglingId = ref<string | null>(null);
|
||||
const defaultingId = ref<string | null>(null);
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
vastUrl: '',
|
||||
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
|
||||
duration: undefined as number | undefined,
|
||||
isDefault: false,
|
||||
});
|
||||
const formData = ref<CreateAdTemplateRequest>(createInitialFormData());
|
||||
|
||||
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
|
||||
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
|
||||
|
||||
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
|
||||
id: item.id || `${item.name || 'template'}:${item.vastTagUrl || item.createdAt || ''}`,
|
||||
name: item.name || '',
|
||||
vastUrl: item.vastTagUrl || '',
|
||||
adFormat: item.adFormat || 'pre-roll',
|
||||
duration: typeof item.duration === 'number' ? item.duration : undefined,
|
||||
enabled: Boolean(item.isActive),
|
||||
isDefault: Boolean(item.isDefault),
|
||||
createdAt: item.createdAt || '',
|
||||
});
|
||||
|
||||
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'ad-templates'],
|
||||
query: async () => {
|
||||
const response = await rpcClient.listAdTemplates();
|
||||
return (response.templates || []).map(mapTemplate);
|
||||
return response.templates || [];
|
||||
},
|
||||
});
|
||||
|
||||
const templates = computed(() => templatesSnapshot.value || []);
|
||||
const templates = computed<AdTemplate[]>(() => templatesSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
|
||||
|
||||
const refetchTemplates = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
const canCreateTemplate = computed(() => !isFreePlan.value && !isInitialLoading.value && !isMutating.value);
|
||||
const canEditDialog = computed(() => !isFreePlan.value && !saving.value);
|
||||
|
||||
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
|
||||
|
||||
@@ -127,13 +87,7 @@ watch(error, (value, previous) => {
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
vastUrl: '',
|
||||
adFormat: 'pre-roll',
|
||||
duration: undefined,
|
||||
isDefault: false,
|
||||
};
|
||||
formData.value = createInitialFormData();
|
||||
editingTemplate.value = null;
|
||||
};
|
||||
|
||||
@@ -148,33 +102,40 @@ const openAddDialog = () => {
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (template: VastTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
const applyTemplateToForm = (template: AdTemplate) => {
|
||||
formData.value = {
|
||||
name: template.name,
|
||||
vastUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
name: template.name || '',
|
||||
description: template.description || '',
|
||||
vastTagUrl: template.vastTagUrl || '',
|
||||
adFormat: template.adFormat || 'pre-roll',
|
||||
duration: template.duration,
|
||||
isDefault: template.isDefault,
|
||||
isActive: template.isActive,
|
||||
isDefault: Boolean(template.isDefault),
|
||||
};
|
||||
};
|
||||
|
||||
const openEditDialog = (template: AdTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
applyTemplateToForm(template);
|
||||
editingTemplate.value = template;
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const buildRequestBody = (enabled = true) => ({
|
||||
name: formData.value.name.trim(),
|
||||
const buildRequestBody = (enabled = true): Parameters<typeof rpcClient.createAdTemplate>[0] => ({
|
||||
...formData.value,
|
||||
name: (formData.value.name || '').trim(),
|
||||
description: '',
|
||||
vastTagUrl: formData.value.vastUrl.trim(),
|
||||
adFormat: formData.value.adFormat,
|
||||
vastTagUrl: (formData.value.vastTagUrl || '').trim(),
|
||||
adFormat: formData.value.adFormat || 'pre-roll',
|
||||
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
|
||||
isActive: enabled,
|
||||
isDefault: enabled ? formData.value.isDefault : false,
|
||||
isDefault: enabled ? Boolean(formData.value.isDefault) : false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !ensurePaidPlan()) return;
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
if (!(formData.value.name || '').trim()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.adsVast.toast.nameRequiredSummary'),
|
||||
@@ -183,7 +144,7 @@ const handleSave = async () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!formData.value.vastUrl.trim()) {
|
||||
if (!(formData.value.vastTagUrl || '').trim()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.adsVast.toast.urlRequiredSummary'),
|
||||
@@ -193,7 +154,7 @@ const handleSave = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(formData.value.vastUrl);
|
||||
new URL(formData.value.vastTagUrl || '');
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
@@ -217,8 +178,8 @@ const handleSave = async () => {
|
||||
try {
|
||||
if (editingTemplate.value) {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: editingTemplate.value.id,
|
||||
...buildRequestBody(editingTemplate.value.enabled),
|
||||
id: editingTemplate.value.id || '',
|
||||
...buildRequestBody(Boolean(editingTemplate.value.isActive)),
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@@ -236,7 +197,7 @@ const handleSave = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
await refetchTemplates();
|
||||
await refetch();
|
||||
closeDialog();
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
@@ -246,30 +207,30 @@ const handleSave = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
const handleToggle = async (template: AdTemplate, nextValue: boolean) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
|
||||
togglingId.value = template.id;
|
||||
togglingId.value = template.id || null;
|
||||
try {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: '',
|
||||
vastTagUrl: template.vastUrl,
|
||||
id: template.id || '',
|
||||
name: template.name || '',
|
||||
description: template.description || '',
|
||||
vastTagUrl: template.vastTagUrl || '',
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
isActive: nextValue,
|
||||
isDefault: nextValue ? template.isDefault : false,
|
||||
isDefault: nextValue ? Boolean(template.isDefault) : false,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: nextValue
|
||||
? t('settings.adsVast.toast.enabledSummary')
|
||||
: t('settings.adsVast.toast.disabledSummary'),
|
||||
detail: t('settings.adsVast.toast.toggleDetail', {
|
||||
name: template.name,
|
||||
name: template.name || '',
|
||||
state: nextValue
|
||||
? t('settings.adsVast.state.enabled')
|
||||
: t('settings.adsVast.state.disabled'),
|
||||
@@ -284,27 +245,27 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (template: VastTemplate) => {
|
||||
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return;
|
||||
const handleSetDefault = async (template: AdTemplate) => {
|
||||
if (Boolean(template.isDefault) || !Boolean(template.isActive) || !ensurePaidPlan()) return;
|
||||
|
||||
defaultingId.value = template.id;
|
||||
defaultingId.value = template.id || null;
|
||||
try {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: '',
|
||||
vastTagUrl: template.vastUrl,
|
||||
id: template.id || '',
|
||||
name: template.name || '',
|
||||
description: template.description || '',
|
||||
vastTagUrl: template.vastTagUrl || '',
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
isActive: template.enabled,
|
||||
isActive: template.isActive,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.defaultUpdatedSummary'),
|
||||
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name }),
|
||||
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name || '' }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
@@ -315,19 +276,19 @@ const handleSetDefault = async (template: VastTemplate) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (template: VastTemplate) => {
|
||||
const handleDelete = (template: AdTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
|
||||
confirm.require({
|
||||
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name }),
|
||||
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name || '' }),
|
||||
header: t('settings.adsVast.confirm.deleteHeader'),
|
||||
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
|
||||
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
|
||||
accept: async () => {
|
||||
deletingId.value = template.id;
|
||||
deletingId.value = template.id || null;
|
||||
try {
|
||||
await rpcClient.deleteAdTemplate({ id: template.id });
|
||||
await refetchTemplates();
|
||||
await rpcClient.deleteAdTemplate({ id: template.id || '' });
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.adsVast.toast.deletedSummary'),
|
||||
@@ -343,158 +304,6 @@ const handleDelete = (template: VastTemplate) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.copiedSummary'),
|
||||
detail: t('settings.adsVast.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const adFormatLabels = computed(() => ({
|
||||
'pre-roll': t('settings.adsVast.formats.preRoll'),
|
||||
'mid-roll': t('settings.adsVast.formats.midRoll'),
|
||||
'post-roll': t('settings.adsVast.formats.postRoll'),
|
||||
}));
|
||||
|
||||
const getAdFormatLabel = (format: string) => adFormatLabels.value[format as keyof typeof adFormatLabels.value] || format;
|
||||
|
||||
const getAdFormatColor = (format: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'pre-roll': 'bg-blue-500/10 text-blue-500',
|
||||
'mid-roll': 'bg-yellow-500/10 text-yellow-500',
|
||||
'post-roll': 'bg-purple-500/10 text-purple-500',
|
||||
};
|
||||
return colors[format] || 'bg-gray-500/10 text-gray-500';
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<VastTemplate>[]>(() => [
|
||||
{
|
||||
id: 'template',
|
||||
header: t('settings.adsVast.table.template'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', [
|
||||
h('div', { class: 'flex flex-wrap items-center gap-2' }, [
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
row.original.isDefault
|
||||
? h('span', {
|
||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary',
|
||||
}, t('settings.adsVast.defaultBadge'))
|
||||
: null,
|
||||
]),
|
||||
h('p', { class: 'mt-0.5 text-xs text-foreground/50' }, t('settings.adsVast.createdOn', { date: row.original.createdAt || '-' })),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'format',
|
||||
header: t('settings.adsVast.table.format'),
|
||||
accessorFn: row => row.adFormat,
|
||||
cell: ({ row }) => h('div', [
|
||||
h('span', {
|
||||
class: ['rounded-full px-2 py-1 text-xs font-medium', getAdFormatColor(row.original.adFormat)],
|
||||
}, getAdFormatLabel(row.original.adFormat)),
|
||||
row.original.adFormat === 'mid-roll' && row.original.duration
|
||||
? h('span', { class: 'ml-2 text-xs text-foreground/50' }, `(${row.original.duration}s)`)
|
||||
: null,
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vastUrl',
|
||||
header: t('settings.adsVast.table.vastUrl'),
|
||||
accessorFn: row => row.vastUrl,
|
||||
cell: ({ row }) => h('div', { class: 'flex max-w-[240px] items-center gap-2' }, [
|
||||
h('code', { class: 'truncate text-xs text-foreground/60' }, row.original.vastUrl),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: isMutating.value,
|
||||
onClick: () => copyToClipboard(row.original.vastUrl),
|
||||
}, {
|
||||
icon: () => h(CheckIcon, { class: 'h-4 w-4' }),
|
||||
}),
|
||||
]),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('common.status'),
|
||||
accessorFn: row => Number(row.enabled),
|
||||
cell: ({ row }) => h('div', { class: 'text-center' }, [
|
||||
h(AppSwitch, {
|
||||
modelValue: row.original.enabled,
|
||||
disabled: isFreePlan.value || saving.value || deletingId.value !== null || defaultingId.value !== null || togglingId.value === row.original.id,
|
||||
'onUpdate:modelValue': (value: boolean) => handleToggle(row.original, value),
|
||||
}),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h('div', { class: 'flex flex-wrap items-center justify-end gap-2' }, [
|
||||
row.original.isDefault
|
||||
? h('span', {
|
||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary',
|
||||
}, t('settings.adsVast.actions.default'))
|
||||
: h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
loading: defaultingId.value === row.original.id,
|
||||
disabled: isFreePlan.value || saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null || !row.original.enabled,
|
||||
onClick: () => handleSetDefault(row.original),
|
||||
}, () => t('settings.adsVast.actions.setDefault')),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: isFreePlan.value || isMutating.value,
|
||||
onClick: () => openEditDialog(row.original),
|
||||
}, {
|
||||
icon: () => h(PencilIcon, { class: 'h-4 w-4' }),
|
||||
}),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: isFreePlan.value || isMutating.value,
|
||||
onClick: () => handleDelete(row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -504,150 +313,36 @@ const columns = computed<ColumnDef<VastTemplate>[]>(() => [
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :disabled="isFreePlan || isInitialLoading || isMutating" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.adsVast.createTemplate') }}
|
||||
</AppButton>
|
||||
<AdsVastToolbar :disabled="!canCreateTemplate" @create="openAddDialog" />
|
||||
</template>
|
||||
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.adsVast.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
<AdsVastNotices :is-free-plan="isFreePlan" />
|
||||
|
||||
<SettingsNotice
|
||||
v-if="isFreePlan"
|
||||
tone="warning"
|
||||
:title="t('settings.adsVast.readOnlyTitle')"
|
||||
class="rounded-none border-x-0 border-t-0 p-3"
|
||||
contentClass="text-xs text-foreground/70"
|
||||
>
|
||||
{{ t('settings.adsVast.readOnlyMessage') }}
|
||||
</SettingsNotice>
|
||||
<AdsVastTable
|
||||
:templates="templates"
|
||||
:is-initial-loading="isInitialLoading"
|
||||
:is-read-only="isFreePlan"
|
||||
:is-mutating="isMutating"
|
||||
:saving="saving"
|
||||
:deleting-id="deletingId"
|
||||
:toggling-id="togglingId"
|
||||
:defaulting-id="defaultingId"
|
||||
@edit="openEditDialog"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggle($event.template, $event.value)"
|
||||
@set-default="handleSetDefault"
|
||||
/>
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="templates"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.adsVast.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.adsVast.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<AppDialog
|
||||
<AdsVastDialog
|
||||
:visible="showAddDialog"
|
||||
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
|
||||
maxWidthClass="max-w-lg"
|
||||
:editing-template="editingTemplate"
|
||||
:form-data="formData"
|
||||
:saving="saving"
|
||||
:can-edit="canEditDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.templateName') }}</label>
|
||||
<AppInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
:disabled="isFreePlan || saving"
|
||||
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="vastUrl" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.vastUrlLabel') }}</label>
|
||||
<AppInput
|
||||
id="vastUrl"
|
||||
v-model="formData.vastUrl"
|
||||
:disabled="isFreePlan || saving"
|
||||
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adFormat') }}</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="format in adFormatOptions"
|
||||
:key="format"
|
||||
type="button"
|
||||
:disabled="isFreePlan || saving"
|
||||
:class="[
|
||||
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
formData.adFormat === format
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-foreground/60 hover:border-primary/50'
|
||||
]"
|
||||
@click="formData.adFormat = format"
|
||||
>
|
||||
{{ getAdFormatLabel(format) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
|
||||
<label for="duration" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adInterval') }}</label>
|
||||
<AppInput
|
||||
id="duration"
|
||||
v-model.number="formData.duration"
|
||||
:disabled="isFreePlan || saving"
|
||||
type="number"
|
||||
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
|
||||
:min="10"
|
||||
:max="600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.defaultLabel') }}</label>
|
||||
<label
|
||||
:class="[
|
||||
'flex items-start gap-3 rounded-md border border-border p-3',
|
||||
canMarkAsDefaultInDialog && !saving ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="formData.isDefault"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canMarkAsDefaultInDialog || saving"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{{ t('settings.adsVast.dialog.defaultCheckbox') }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
{{ editingTemplate && !editingTemplate.enabled
|
||||
? t('settings.adsVast.dialog.defaultDisabledHint')
|
||||
: t('settings.adsVast.dialog.defaultHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="saving" @click="closeDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="saving" :disabled="isFreePlan" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ editingTemplate ? t('settings.adsVast.dialog.update') : t('settings.adsVast.dialog.create') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
194
src/routes/settings/AdsVast/components/AdsVastDialog.vue
Normal file
194
src/routes/settings/AdsVast/components/AdsVastDialog.vue
Normal 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>
|
||||
26
src/routes/settings/AdsVast/components/AdsVastNotices.vue
Normal file
26
src/routes/settings/AdsVast/components/AdsVastNotices.vue
Normal 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>
|
||||
252
src/routes/settings/AdsVast/components/AdsVastTable.tsx
Normal file
252
src/routes/settings/AdsVast/components/AdsVastTable.tsx
Normal 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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
24
src/routes/settings/AdsVast/components/AdsVastToolbar.vue
Normal file
24
src/routes/settings/AdsVast/components/AdsVastToolbar.vue
Normal 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>
|
||||
6
src/routes/settings/AdsVast/types.ts
Normal file
6
src/routes/settings/AdsVast/types.ts
Normal 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';
|
||||
@@ -7,11 +7,11 @@ import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
|
||||
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
|
||||
import BillingTopupDialog from '@/routes/settings/components/billing/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/components/billing/BillingUsageSection.vue';
|
||||
import BillingWalletRow from '@/routes/settings/components/billing/BillingWalletRow.vue';
|
||||
import BillingHistorySection from '@/routes/settings/Billing/components/BillingHistorySection.vue';
|
||||
import BillingPlansSection from '@/routes/settings/Billing/components/BillingPlansSection.vue';
|
||||
import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
|
||||
import BillingWalletRow from '@/routes/settings/Billing/components/BillingWalletRow.vue';
|
||||
import type { Plan as ModelPlan, PaymentHistoryItem as PaymentHistoryApiItem } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
|
||||
@@ -16,7 +16,6 @@ defineProps<{
|
||||
hint: string;
|
||||
cancelLabel: string;
|
||||
proceedLabel: string;
|
||||
formatMoney: (amount: number) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -44,14 +43,14 @@ const emit = defineEmits<{
|
||||
v-for="preset in presets"
|
||||
:key="preset"
|
||||
:class="[
|
||||
'py-2 px-3 rounded-md text-sm font-medium transition-all',
|
||||
'py-2 px-3 rounded-md bg-header text-sm font-medium transition-all hover:bg-gray-500',
|
||||
amount === preset
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-muted/50 text-foreground hover:bg-muted'
|
||||
]"
|
||||
@click="emit('selectPreset', preset)"
|
||||
>
|
||||
{{ formatMoney(preset) }}
|
||||
${{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import SettingsRow from '../SettingsRow.vue';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -1,68 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import DomainsDnsDialog from './components/DomainsDnsDialog.vue';
|
||||
import DomainsDnsEmbedCode from './components/DomainsDnsEmbedCode.vue';
|
||||
import DomainsDnsNotices from './components/DomainsDnsNotices.vue';
|
||||
import DomainsDnsTable from './components/DomainsDnsTable.vue';
|
||||
import DomainsDnsToolbar from './components/DomainsDnsToolbar.vue';
|
||||
import { mapDomainItem, normalizeDomainInput } from './helpers';
|
||||
import type { DomainItem } from './types';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
|
||||
const newDomain = ref('');
|
||||
const showAddDialog = ref(false);
|
||||
const adding = ref(false);
|
||||
const removingId = ref<string | null>(null);
|
||||
|
||||
const normalizeDomainInput = (value: string) => value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.split('T')[0] || value;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
|
||||
name: item.name || '',
|
||||
addedAt: formatDate(item.created_at),
|
||||
});
|
||||
|
||||
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
@@ -73,13 +33,8 @@ const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||
|
||||
const domains = computed(() => domainsSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
|
||||
|
||||
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
||||
|
||||
const refetchDomains = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || adding.value || removingId.value !== null) return;
|
||||
|
||||
@@ -105,7 +60,7 @@ const handleAddDomain = async () => {
|
||||
if (adding.value) return;
|
||||
|
||||
const domainName = normalizeDomainInput(newDomain.value);
|
||||
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
|
||||
if (!domainName || !domainName.includes('.') || /[/\s]/.test(domainName)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
@@ -132,7 +87,7 @@ const handleAddDomain = async () => {
|
||||
name: domainName,
|
||||
});
|
||||
|
||||
await refetchDomains();
|
||||
await refetch();
|
||||
closeAddDialog();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@@ -181,7 +136,7 @@ const handleRemoveDomain = (domain: DomainItem) => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await rpcClient.deleteDomain({ id: domain.id });
|
||||
await refetchDomains();
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||
@@ -222,49 +177,6 @@ const copyIframeCode = async () => {
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<DomainItem>[]>(() => [
|
||||
{
|
||||
id: 'domain',
|
||||
header: t('settings.domainsDns.table.domain'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(LinkIcon, { class: 'h-4 w-4 text-foreground/40' }),
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'addedAt',
|
||||
header: t('settings.domainsDns.table.addedDate'),
|
||||
accessorFn: row => row.addedAt,
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-foreground/60' }, row.original.addedAt),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: adding.value || removingId.value !== null,
|
||||
onClick: () => handleRemoveDomain(row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -274,96 +186,33 @@ const columns = computed<ColumnDef<DomainItem>[]>(() => [
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="domains"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
|
||||
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.copyCode') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/60 mb-2">
|
||||
{{ t('settings.domainsDns.embedCodeHint') }}
|
||||
</p>
|
||||
<pre class="bg-header border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ iframeCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
:title="t('settings.domainsDns.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@close="closeAddDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
|
||||
<AppInput
|
||||
id="domain"
|
||||
v-model="newDomain"
|
||||
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
|
||||
@enter="handleAddDomain"
|
||||
<DomainsDnsToolbar
|
||||
:loading="adding"
|
||||
:disabled="isInitialLoading || removingId !== null"
|
||||
@create="openAddDialog"
|
||||
/>
|
||||
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
|
||||
</div>
|
||||
|
||||
<SettingsNotice
|
||||
tone="warning"
|
||||
:title="t('settings.domainsDns.dialog.importantTitle')"
|
||||
class="p-3"
|
||||
>
|
||||
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
|
||||
</SettingsNotice>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<DomainsDnsNotices />
|
||||
|
||||
<DomainsDnsTable
|
||||
:domains="domains"
|
||||
:is-initial-loading="isInitialLoading"
|
||||
:adding="adding"
|
||||
:removing-id="removingId"
|
||||
@remove="handleRemoveDomain"
|
||||
/>
|
||||
|
||||
<DomainsDnsEmbedCode :code="iframeCode" @copy="copyIframeCode" />
|
||||
|
||||
<DomainsDnsDialog
|
||||
:visible="showAddDialog"
|
||||
:domain="newDomain"
|
||||
:adding="adding"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@update:domain="newDomain = $event"
|
||||
@submit="handleAddDomain"
|
||||
@close="closeAddDialog"
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
25
src/routes/settings/DomainsDns/helpers.ts
Normal file
25
src/routes/settings/DomainsDns/helpers.ts
Normal 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),
|
||||
});
|
||||
11
src/routes/settings/DomainsDns/types.ts
Normal file
11
src/routes/settings/DomainsDns/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
@@ -65,10 +65,6 @@ const notificationTypes = computed(() => [
|
||||
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
|
||||
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
|
||||
|
||||
const refetchPreferences = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
watch(preferencesSnapshot, (snapshot) => {
|
||||
if (!snapshot) return;
|
||||
notificationSettings.value = createNotificationSettingsDraft(snapshot);
|
||||
@@ -93,7 +89,7 @@ const handleSave = async () => {
|
||||
await rpcClient.updatePreferences(
|
||||
toNotificationPreferencesPayload(notificationSettings.value),
|
||||
);
|
||||
await refetchPreferences();
|
||||
await refetch();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
402
src/routes/settings/PlayerConfigs/PlayerConfigs.vue
Normal file
402
src/routes/settings/PlayerConfigs/PlayerConfigs.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
50
src/routes/settings/PlayerConfigs/types.ts
Normal file
50
src/routes/settings/PlayerConfigs/types.ts
Normal 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;
|
||||
}
|
||||
@@ -1,166 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import {
|
||||
createPlayerSettingsDraft,
|
||||
toPlayerPreferencesPayload,
|
||||
useSettingsPreferencesQuery,
|
||||
} from '@/composables/useSettingsPreferencesQuery';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
import SettingsRowSkeleton from '@/routes/settings/components/SettingsRowSkeleton.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const toast = useAppToast();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
|
||||
|
||||
const playerSettings = ref(createPlayerSettingsDraft());
|
||||
const saving = ref(false);
|
||||
|
||||
const settingsItems = computed(() => [
|
||||
{
|
||||
key: 'autoplay' as const,
|
||||
title: 'settings.playerSettings.items.autoplay.title',
|
||||
description: 'settings.playerSettings.items.autoplay.description',
|
||||
svg: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 404"><path d="M26 45v314c0 10 9 19 19 19 5 0 9-2 13-5l186-157c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19z" class="fill-primary/30"/><path d="M26 359c0 11 9 19 19 19 5 0 9-2 13-4l186-158c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19v314zm-16 0V45c0-19 16-35 35-35 8 0 17 3 23 8l186 158c8 6 12 16 12 26s-4 20-12 26L68 386c-6 5-15 8-23 8-19 0-35-16-35-35zM378 18v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8zm144 0v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'loop' as const,
|
||||
title: 'settings.playerSettings.items.loop.title',
|
||||
description: 'settings.playerSettings.items.loop.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497 496"><path d="M80 248c0 30 8 58 21 82h65c8 0 14 6 14 14s-6 14-14 14h-45c31 36 76 58 127 58 93 0 168-75 168-168 0-30-8-58-21-82h-65c-8 0-14-6-14-14s6-14 14-14h45c-31-35-76-58-127-58-93 0-168 75-168 168z" class="fill-primary/30"/><path d="M70 358c37 60 103 100 179 100 116 0 210-94 210-210 0-8 6-14 14-14 7 0 14 6 14 14 0 131-107 238-238 238-82 0-154-41-197-104v90c0 8-6 14-14 14s-14-6-14-14V344c0-8 6-14 14-14h128c8 0 14 6 14 14s-6 14-14 14H70zm374-244V24c0-8 6-14 14-14s14 6 14 14v128c0 8-6 14-14 14H330c-8 0-14-6-14-14s6-14 14-14h96C389 78 323 38 248 38 132 38 38 132 38 248c0 8-7 14-14 14-8 0-14-6-14-14C10 117 116 10 248 10c81 0 153 41 196 104z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'muted' as const,
|
||||
title: 'settings.playerSettings.items.muted.title',
|
||||
description: 'settings.playerSettings.items.muted.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 549 468"><path d="M26 186v96c0 18 14 32 32 32h64c4 0 8 2 11 5l118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32z" class="fill-primary/30"/><path d="m133 319 118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32v96c0 18 14 32 32 32h64c4 0 8 2 11 5zM58 138h64L240 20c7-6 15-10 24-10 19 0 34 15 34 34v380c0 19-15 34-34 34-9 0-17-4-24-10L122 330H58c-26 0-48-21-48-48v-96c0-26 22-48 48-48zm322 18c3-3 9-3 12 0l66 67 66-67c3-3 8-3 12 0 3 3 3 9 0 12l-67 66 67 66c3 3 3 8 0 12-4 3-9 3-12 0l-66-67-66 67c-3 3-9 3-12 0-3-4-3-9 0-12l67-66-67-66c-3-3-3-9 0-12z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'showControls' as const,
|
||||
title: 'settings.playerSettings.items.showControls.title',
|
||||
description: 'settings.playerSettings.items.showControls.description',
|
||||
svg: `<svg class="h6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M26 74v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74c0-26-21-48-48-48H74c-26 0-48 22-48 48zm48 72c0-4 4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8zm0 176c0-4 4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8s-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8z" class="fill-primary/30"/><path d="M442 74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74zm16 320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h320c35 0 64 29 64 64v320zm-64-72c0 4-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8zm-112 0v32c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16v32zm104-184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184zm-232-24v64c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'pip' as const,
|
||||
title: 'settings.playerSettings.items.pip.title',
|
||||
description: 'settings.playerSettings.items.pip.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v112H314c-53 0-96 43-96 96v160H74c-26 0-48-21-48-48V74z" class="fill-primary/30"/><path d="M458 10c35 0 64 29 64 64v112h-16V74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h144v16H68c-31-3-55-27-58-57V74c0-33 25-60 58-64h390zm16 224c27 0 48 22 48 48v133c-3 24-23 43-48 43H309c-22-2-40-20-43-43V282c0-26 22-48 48-48h160zm-160 16c-18 0-32 14-32 32v128c0 18 14 32 32 32h160c18 0 32-14 32-32V282c0-18-14-32-32-32H314z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'airplay' as const,
|
||||
title: 'settings.playerSettings.items.airplay.title',
|
||||
description: 'settings.playerSettings.items.airplay.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 436"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v224c0 26-21 47-47 48-45-45-91-91-136-137-32-31-82-31-114 0-45 46-91 91-136 137-26-1-47-22-47-48V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v224c0 26 21 47 47 48l-14 14c-28-7-49-32-49-62V74c0-35 29-64 64-64h384c35 0 64 29 64 64v224c0 30-21 55-49 62l-14-14c26-1 47-22 47-48V74c0-26-21-48-48-48zM138 410h256c7 0 12-4 15-10 2-6 1-13-4-17L277 255c-6-6-16-6-22 0L127 383c-5 4-6 11-4 17 3 6 9 10 15 10zm279-39c9 10 12 23 7 35s-17 20-30 20H138c-13 0-25-8-30-20s-2-25 7-35l128-128c13-12 33-12 46 0l128 128z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'chromecast' as const,
|
||||
title: 'settings.playerSettings.items.chromecast.title',
|
||||
description: 'settings.playerSettings.items.chromecast.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v320c0 27-21 48-48 48H314v-9c0-154-125-279-279-279h-9V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v80H10V74c0-35 29-64 64-64h384c35 0 64 29 64 64v320c0 35-29 64-64 64H314v-16h144c27 0 48-21 48-48V74c0-26-21-48-48-48zM18 202c137 0 248 111 248 248 0 4-4 8-8 8s-8-4-8-8c0-128-104-232-232-232-4 0-8-4-8-8s4-8 8-8zm40 224c0-9-7-16-16-16s-16 7-16 16 7 16 16 16 16-7 16-16zm-48 0c0-18 14-32 32-32s32 14 32 32-14 32-32 32-32-14-32-32zm0-120c0-4 4-8 8-8 84 0 152 68 152 152 0 4-4 8-8 8s-8-4-8-8c0-75-61-136-136-136-4 0-8-4-8-8z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'encrytion_m3u8' as const,
|
||||
title: 'settings.playerSettings.items.encrytion_m3u8.title',
|
||||
description: 'settings.playerSettings.items.encrytion_m3u8.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="fill-primary/30" viewBox="0 0 564 564"><path d="M26 74c0-26 22-48 48-48h134c3 0 7 0 10 1v103c0 31 25 56 56 56h120v11c-38 18-64 56-64 101v29c-29 16-48 47-48 83v96H74c-26 0-48-21-48-48V74z"/><path d="M208 26H74c-26 0-48 22-48 48v384c0 27 22 48 48 48h208c0 6 1 11 1 16H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h134c17 0 33 7 45 19l122 122c10 10 16 22 18 35H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1zm156 137L241 40c-2-2-4-4-7-6v96c0 22 18 40 40 40h96c-2-3-4-5-6-7zm126 135c0-26-21-48-48-48-26 0-48 22-48 48v64h96v-64zM346 410v96c0 18 14 32 32 32h128c18 0 32-14 32-32v-96c0-18-14-32-32-32H378c-18 0-32 14-32 32zm160-112v64c27 0 48 22 48 48v96c0 27-21 48-48 48H378c-26 0-48-21-48-48v-96c0-26 22-48 48-48v-64c0-35 29-64 64-64s64 29 64 64z" class="fill-primary"/></svg>`,
|
||||
|
||||
},
|
||||
]);
|
||||
|
||||
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
|
||||
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
|
||||
|
||||
|
||||
watch(preferencesSnapshot, (snapshot) => {
|
||||
if (!snapshot) return;
|
||||
playerSettings.value = createPlayerSettingsDraft(snapshot);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || saving.value) return;
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||
detail: (value as any)?.message || t('settings.playerSettings.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
onMounted(() => {
|
||||
router.replace({ name: 'settings-player-configs' });
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !preferencesSnapshot.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await rpcClient.updatePreferences(
|
||||
toPlayerPreferencesPayload(playerSettings.value),
|
||||
);
|
||||
await refetch();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.playerSettings.toast.savedSummary'),
|
||||
detail: t('settings.playerSettings.toast.savedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.player.title')"
|
||||
:description="t('settings.content.player.subtitle')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<template v-if="isInitialLoading">
|
||||
<SettingsRowSkeleton
|
||||
v-for="item in settingsItems"
|
||||
:key="item.key"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SettingsRow
|
||||
v-for="item in settingsItems"
|
||||
:key="item.key"
|
||||
:title="$t(item.title)"
|
||||
:description="$t(item.description)"
|
||||
iconBoxClass="bg-primary/10 text-primary"
|
||||
>
|
||||
<template #icon>
|
||||
<span v-html="item.svg" class="h-6 w-6" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppSwitch v-model="playerSettings[item.key]" :disabled="isInteractionDisabled" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
</SettingsSectionCard>
|
||||
<div class="p-4 text-sm text-foreground/60">Redirecting...</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LockIcon from '@/components/icons/LockIcon.vue';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { supportedLocales } from '@/i18n/constants';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import SecurityAccountStatusRow from './components/SecurityAccountStatusRow.vue';
|
||||
import SecurityChangePasswordDialog from './components/SecurityChangePasswordDialog.vue';
|
||||
import SecurityChangePasswordRow from './components/SecurityChangePasswordRow.vue';
|
||||
import SecurityEmailRow from './components/SecurityEmailRow.vue';
|
||||
import SecurityLanguageRow from './components/SecurityLanguageRow.vue';
|
||||
import SecurityLogoutRow from './components/SecurityLogoutRow.vue';
|
||||
import SecurityTelegramRow from './components/SecurityTelegramRow.vue';
|
||||
import SecurityTwoFactorDialog from './components/SecurityTwoFactorDialog.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useAppToast();
|
||||
@@ -191,275 +191,50 @@ const disconnectTelegram = async () => {
|
||||
:title="t('settings.securityConnected.header.title')"
|
||||
:description="t('settings.securityConnected.header.subtitle')"
|
||||
>
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.accountStatus.label')"
|
||||
:description="t('settings.securityConnected.accountStatus.detail')"
|
||||
>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</template>
|
||||
<SecurityAccountStatusRow />
|
||||
|
||||
<template #actions>
|
||||
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SecurityLanguageRow
|
||||
:selected-language="selectedLanguage"
|
||||
:language-options="languageOptions"
|
||||
:language-saving="languageSaving"
|
||||
@update:selected-language="selectedLanguage = $event"
|
||||
@save="saveLanguage"
|
||||
/>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.language.label')"
|
||||
:description="t('settings.securityConnected.language.detail')"
|
||||
actionsClass="flex items-center gap-2"
|
||||
>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15 15 0 0 1 0 20" />
|
||||
<path d="M12 2a15 15 0 0 0 0 20" />
|
||||
</svg>
|
||||
</template>
|
||||
<SecurityChangePasswordRow @open="openChangePassword" />
|
||||
|
||||
<template #actions>
|
||||
<select
|
||||
v-model="selectedLanguage"
|
||||
:disabled="languageSaving"
|
||||
class="rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<AppButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:loading="languageSaving"
|
||||
:disabled="languageSaving"
|
||||
@click="saveLanguage"
|
||||
>
|
||||
{{ t('settings.securityConnected.language.save') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SecurityEmailRow :email-connected="emailConnected" />
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.changePassword.label')"
|
||||
:description="t('settings.securityConnected.changePassword.detail')"
|
||||
>
|
||||
<template #icon>
|
||||
<svg aria-hidden="true" class="fill-primary w-6 h-6" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
|
||||
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<SecurityTelegramRow
|
||||
:telegram-connected="telegramConnected"
|
||||
:telegram-username="telegramUsername"
|
||||
@connect="connectTelegram"
|
||||
@disconnect="disconnectTelegram"
|
||||
/>
|
||||
|
||||
<template #actions>
|
||||
<AppButton variant="secondary" size="sm" @click="openChangePassword">
|
||||
{{ t('settings.securityConnected.changePassword.button') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.email.label')"
|
||||
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
|
||||
>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="text-info w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
|
||||
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
|
||||
</span>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.telegram.label')"
|
||||
:description="telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected')"
|
||||
>
|
||||
<template #icon>
|
||||
<TelegramIcon class="w-6 h-6 text-[#0088cc]" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton
|
||||
v-if="telegramConnected"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="disconnectTelegram"
|
||||
>
|
||||
{{ t('settings.securityConnected.telegram.disconnect') }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-else
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="connectTelegram"
|
||||
>
|
||||
{{ t('settings.securityConnected.telegram.connect') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.logout.label')"
|
||||
:description="t('settings.securityConnected.logout.detail')"
|
||||
hoverClass="hover:bg-danger/5"
|
||||
>
|
||||
<template #icon>
|
||||
<XCircleIcon class="w-6 h-6 text-danger" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton variant="danger" size="sm" @click="handleLogout">
|
||||
<template #icon>
|
||||
<XCircleIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.securityConnected.logout.button') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SecurityLogoutRow @logout="handleLogout" />
|
||||
</SettingsSectionCard>
|
||||
|
||||
<AppDialog
|
||||
<SecurityTwoFactorDialog
|
||||
:visible="twoFactorDialogVisible"
|
||||
:two-factor-code="twoFactorCode"
|
||||
:two-factor-secret="twoFactorSecret"
|
||||
@update:visible="twoFactorDialogVisible = $event"
|
||||
:title="t('settings.securityConnected.twoFactorDialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center py-4">
|
||||
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/30 rounded-md p-3">
|
||||
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
|
||||
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
|
||||
<AppInput
|
||||
id="twoFactorCode"
|
||||
v-model="twoFactorCode"
|
||||
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
|
||||
:maxlength="6"
|
||||
@update:two-factor-code="twoFactorCode = $event"
|
||||
@confirm="confirmTwoFactor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
|
||||
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="confirmTwoFactor">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
<SecurityChangePasswordDialog
|
||||
:visible="changePasswordDialogVisible"
|
||||
@update:visible="changePasswordDialogVisible = $event"
|
||||
:title="t('settings.securityConnected.changePassword.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
|
||||
</p>
|
||||
|
||||
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
|
||||
{{ changePasswordError }}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
|
||||
<AppInput
|
||||
id="currentPassword"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
|
||||
<AppInput
|
||||
id="newPassword"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
|
||||
<AppInput
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="changePasswordLoading"
|
||||
@click="changePasswordDialogVisible = false"
|
||||
>
|
||||
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
:current-password="currentPassword"
|
||||
:new-password="newPassword"
|
||||
:confirm-password="confirmPassword"
|
||||
:loading="changePasswordLoading"
|
||||
@click="changePassword"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
:error="changePasswordError"
|
||||
@update:visible="changePasswordDialogVisible = $event"
|
||||
@update:current-password="currentPassword = $event"
|
||||
@update:new-password="newPassword = $event"
|
||||
@update:confirm-password="confirmPassword = $event"
|
||||
@submit="changePassword"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader
|
||||
:title="content[route.name as keyof typeof content]?.title || t('settings.content.fallbackTitle')"
|
||||
<PageHeader :title="content[route.name as keyof typeof content]?.title || t('settings.content.fallbackTitle')"
|
||||
:description="content[route.name as keyof typeof content]?.subtitle || t('settings.content.fallbackSubtitle')"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
/>
|
||||
:breadcrumbs="breadcrumbs" />
|
||||
<div class="max-w-7xl mx-auto pb-12">
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 mt-6">
|
||||
@@ -15,29 +13,29 @@
|
||||
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('app.name') }}</h3>
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('app.name') }}
|
||||
</h3>
|
||||
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="space-y-6">
|
||||
<div v-for="section in menuSections" :key="section.title">
|
||||
<h3 v-if="section.title" class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
|
||||
<h3 v-if="section.title"
|
||||
class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="item in section.items" :key="item.value">
|
||||
<router-link
|
||||
:to="tabPaths[item.value]"
|
||||
:class="[
|
||||
<router-link :to="item.to" :class="[
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
|
||||
currentTab === item.value
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: item.danger
|
||||
? 'text-danger hover:bg-danger/10'
|
||||
: 'text-foreground/70 hover:bg-header hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" />
|
||||
]">
|
||||
<component :is="item.icon" class="w-5 h-5 shrink-0"
|
||||
:filled="currentTab === item.value" />
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</li>
|
||||
@@ -77,71 +75,100 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, createStaticVNode } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { isAdmin } from '@/lib/utils';
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
// Map tab values to their paths
|
||||
const tabPaths: Record<string, string> = {
|
||||
profile: '/settings',
|
||||
security: '/settings/security',
|
||||
notifications: '/settings/notifications',
|
||||
player: '/settings/player',
|
||||
billing: '/settings/billing',
|
||||
domains: '/settings/domains',
|
||||
ads: '/settings/ads',
|
||||
danger: '/settings/danger',
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
to: string
|
||||
value: string
|
||||
label: string
|
||||
icon?: any
|
||||
description?: string
|
||||
danger?: boolean
|
||||
}
|
||||
// Menu items grouped by category (GitHub-style)
|
||||
const menuSections = computed<{ title: string; items: { value: string; label: string; icon: any, danger?: boolean }[] }[]>(() => [
|
||||
const menuSections = computed<{ title: string; items: MenuItem[] }[]>(() => [
|
||||
{
|
||||
title: t('settings.menu.securityGroup'),
|
||||
items: [
|
||||
{ value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(`<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -258 596 564"><path d="M144-120c0-44 36-80 80-80s80 36 80 80-36 80-80 80-80-36-80-80zm208 0c0-71-57-128-128-128S96-191 96-120 153 8 224 8s128-57 128-128zM48 232c0-71 57-128 128-128h64V77c0-7 1-14 3-21h-67C79 56 0 135 0 232v8c0 13 11 24 24 24s24-11 24-24v-8zm397 9-13 6V59l96 32v19c0 56-32 107-83 131zM422 12 310 49c-13 4-22 16-22 30v31c0 75 43 142 110 174l19 9c5 2 10 3 15 3s10-1 15-3l19-9c67-32 110-99 110-174V79c0-14-9-26-22-30L442 11c-6-2-14-2-20 0zm0 0z" fill="currentColor"/></svg>`, 1) },
|
||||
{ value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
|
||||
{
|
||||
to: '/settings/security',
|
||||
value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(`<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -258 596 564"><path d="M144-120c0-44 36-80 80-80s80 36 80 80-36 80-80 80-80-36-80-80zm208 0c0-71-57-128-128-128S96-191 96-120 153 8 224 8s128-57 128-128zM48 232c0-71 57-128 128-128h64V77c0-7 1-14 3-21h-67C79 56 0 135 0 232v8c0 13 11 24 24 24s24-11 24-24v-8zm397 9-13 6V59l96 32v19c0 56-32 107-83 131zM422 12 310 49c-13 4-22 16-22 30v31c0 75 43 142 110 174l19 9c5 2 10 3 15 3s10-1 15-3l19-9c67-32 110-99 110-174V79c0-14-9-26-22-30L442 11c-6-2-14-2-20 0zm0 0z" fill="currentColor"/></svg>`, 1)
|
||||
},
|
||||
{ to: '/settings/billing', value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.menu.preferencesGroup'),
|
||||
items: [
|
||||
{ value: 'notifications', label: t('settings.menu.notifications'), icon: Bell },
|
||||
{ value: 'player', label: t('settings.menu.player'), icon: VideoPlayIcon },
|
||||
{ to: '/settings/notifications', value: 'notifications', label: t('settings.menu.notifications'), icon: Bell },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.menu.playerGroup'),
|
||||
items: [
|
||||
{ to: '/settings/player-configs', value: 'player-configs', label: t('settings.menu.playerConfigs'), icon: VideoPlayIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.menu.integrationsGroup'),
|
||||
items: [
|
||||
{ value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon },
|
||||
{ value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon },
|
||||
{ to: '/settings/domains', value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon },
|
||||
{ to: '/settings/ads', value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon },
|
||||
],
|
||||
},
|
||||
...(isAdmin(auth.user?.role) ? [{
|
||||
title: 'Admin Workspace',
|
||||
items: [
|
||||
{ to: '/settings/admin/users', value: 'admin-users', label: 'Users', description: 'Accounts, plans and moderation' },
|
||||
{ to: '/settings/admin/videos', value: 'admin-videos', label: 'Videos', description: 'Cross-user media inventory' },
|
||||
{ to: '/settings/admin/payments', value: 'admin-payments', label: 'Payments', description: 'Revenue, invoices and state changes' },
|
||||
{ to: '/settings/admin/plans', value: 'admin-plans', label: 'Plans', description: 'Catalog and subscription offers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin Operations',
|
||||
items: [
|
||||
{ to: '/settings/admin/ad-templates', value: 'admin-ad-templates', label: 'Ad Templates', description: 'VAST templates and defaults' },
|
||||
{ to: '/settings/admin/player-configs', value: 'admin-player-configs', label: 'Player Configs', description: 'Cross-user player presets and defaults' },
|
||||
{ to: '/settings/admin/jobs', value: 'admin-jobs', label: 'Jobs', description: 'Queue, retries and live logs' },
|
||||
{ to: '/settings/admin/agents', value: 'admin-agents', label: 'Agents', description: 'Workers, health and maintenance' },
|
||||
{ to: '/settings/admin/logs', value: 'admin-logs', label: 'Logs', description: 'Direct runtime log lookup' },
|
||||
],
|
||||
},] : []),
|
||||
{
|
||||
title: t('settings.menu.dangerGroup'),
|
||||
items: [
|
||||
{ value: 'danger', label: t('settings.menu.danger'), icon: AlertTriangle, danger: true },
|
||||
{ to: '/settings/danger', value: 'danger', label: t('settings.menu.danger'), icon: AlertTriangle, danger: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
] as const);
|
||||
|
||||
type TabValue = 'profile' | 'security' | 'notifications' | 'player' | 'billing' | 'domains' | 'ads' | 'danger';
|
||||
type TabValue = 'profile' | 'security' | 'notifications' | 'playerConfigs' | 'billing' | 'domains' | 'ads' | 'danger';
|
||||
|
||||
// Get current tab from route path
|
||||
const currentTab = computed<TabValue>(() => {
|
||||
const path = route.path as string;
|
||||
const tabName = path.replace('/settings', '') || '/profile';
|
||||
// support admin sub-routes
|
||||
if (tabName.startsWith('/admin/')) {
|
||||
return tabName.replace('/admin/', 'admin-') as TabValue;
|
||||
}
|
||||
if (tabName === '' || tabName === '/') return 'profile';
|
||||
return (tabName.replace('/', '') as TabValue) || 'profile';
|
||||
});
|
||||
|
||||
// Breadcrumbs with dynamic tab
|
||||
const allMenuItems = computed(() => menuSections.value.flatMap(section => section.items));
|
||||
const allMenuItems = computed(() => menuSections.value.map(section => section.items).flat());
|
||||
const currentItem = computed(() => allMenuItems.value.find(item => item.value === currentTab.value));
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: t('pageHeader.dashboard'), to: '/overview' },
|
||||
{ label: t('pageHeader.settings'), to: '/settings' },
|
||||
...(currentItem.value ? [{ label: currentItem.value.label }] : []),
|
||||
...(currentItem.value ? [{ label: currentItem.value.label + (currentItem.value.value.includes("admin") ? " (Admin)" : "") }] : []),
|
||||
]);
|
||||
|
||||
const content = computed(() => ({
|
||||
@@ -153,10 +180,6 @@ const content = computed(() => ({
|
||||
title: t('settings.content.notifications.title'),
|
||||
subtitle: t('settings.content.notifications.subtitle')
|
||||
},
|
||||
'settings-player': {
|
||||
title: t('settings.content.player.title'),
|
||||
subtitle: t('settings.content.player.subtitle')
|
||||
},
|
||||
'settings-billing': {
|
||||
title: t('settings.content.billing.title'),
|
||||
subtitle: t('settings.content.billing.subtitle')
|
||||
@@ -169,9 +192,53 @@ const content = computed(() => ({
|
||||
title: t('settings.content.ads.title'),
|
||||
subtitle: t('settings.content.ads.subtitle')
|
||||
},
|
||||
'settings-player-configs': {
|
||||
title: t('settings.content.playerConfigs.title'),
|
||||
subtitle: t('settings.content.playerConfigs.subtitle')
|
||||
},
|
||||
'settings-danger': {
|
||||
title: t('settings.content.danger.title'),
|
||||
subtitle: t('settings.content.danger.subtitle')
|
||||
}
|
||||
},
|
||||
'admin-overview': {
|
||||
title: 'Overview',
|
||||
subtitle: 'KPIs, usage and runtime pulse across the admin workspace.',
|
||||
},
|
||||
'admin-users': {
|
||||
title: 'Users',
|
||||
subtitle: 'Accounts, plans and moderation tools for the full user base.',
|
||||
},
|
||||
'admin-videos': {
|
||||
title: 'Videos',
|
||||
subtitle: 'Cross-user media inventory, review and operational controls.',
|
||||
},
|
||||
'admin-payments': {
|
||||
title: 'Payments',
|
||||
subtitle: 'Revenue records, invoices and payment state operations.',
|
||||
},
|
||||
'admin-plans': {
|
||||
title: 'Plans',
|
||||
subtitle: 'Subscription catalog management and offer maintenance.',
|
||||
},
|
||||
'admin-ad-templates': {
|
||||
title: 'Ad Templates',
|
||||
subtitle: 'VAST templates, ownership metadata and default assignments.',
|
||||
},
|
||||
'admin-player-configs': {
|
||||
title: 'Player Configs',
|
||||
subtitle: 'Cross-user player presets, flags and default assignments.',
|
||||
},
|
||||
'admin-jobs': {
|
||||
title: 'Jobs',
|
||||
subtitle: 'Queue state, retries and runtime execution tracking.',
|
||||
},
|
||||
'admin-agents': {
|
||||
title: 'Agents',
|
||||
subtitle: 'Connected workers, health checks and maintenance actions.',
|
||||
},
|
||||
'admin-logs': {
|
||||
title: 'Logs',
|
||||
subtitle: 'Persisted output lookup and live runtime tailing.',
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import AdminSelect from "./components/AdminSelect.vue";
|
||||
import AdminTextarea from "./components/AdminTextarea.vue";
|
||||
import AdminTable from "./components/AdminTable.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
@@ -310,24 +313,25 @@ const columns = computed<ColumnDef<AdminAdTemplateRow>[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
// useAdminPageHeader(() => ({
|
||||
// eyebrow: "Advertising",
|
||||
// badge: `${total.value} total templates`,
|
||||
// actions: [
|
||||
// {
|
||||
// label: "Refresh",
|
||||
// variant: "secondary",
|
||||
// onClick: loadTemplates,
|
||||
// },
|
||||
// {
|
||||
// label: "Create template",
|
||||
// onClick: () => {
|
||||
// actionError.value = null;
|
||||
// createOpen.value = true;
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// }));
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: 'Advertising',
|
||||
badge: loading.value ? 'Syncing template inventory' : `${total.value} total templates`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
onClick: loadTemplates,
|
||||
},
|
||||
{
|
||||
label: 'Create template',
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(loadTemplates);
|
||||
</script>
|
||||
@@ -336,40 +340,38 @@ onMounted(loadTemplates);
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SettingsSectionCard title="Filters" description="Search templates by name and narrow by owner reference if needed." bodyClass="p-5">
|
||||
<AdminSectionCard title="Filters" description="Search templates by name and narrow by owner reference if needed." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_220px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search template name" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">Search</label>
|
||||
<AdminInput v-model="search" placeholder="Search template name" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Owner reference</label>
|
||||
<AppInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">Owner reference</label>
|
||||
<AdminInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; loadTemplates()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||
|
||||
<SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" variant="ghost" @click="loadTemplates">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="createOpen = true; actionError = null">Create template</AppButton>
|
||||
</template>
|
||||
<AdminSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
<AdminTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -385,16 +387,16 @@ onMounted(loadTemplates);
|
||||
<p class="text-xs text-foreground/40">Try a broader template name or clear the owner filter.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</AdminTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="text-xs text-foreground/55">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
|
||||
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
@@ -406,12 +408,12 @@ onMounted(loadTemplates);
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">VAST URL</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">VAST URL</div>
|
||||
<div class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.vastTagUrl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,36 +431,36 @@ onMounted(loadTemplates);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
|
||||
<AdminInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Preroll template" />
|
||||
<label class="text-sm font-medium text-foreground/70">Name</label>
|
||||
<AdminInput v-model="createForm.name" placeholder="Preroll template" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Description</label>
|
||||
<AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">VAST URL</label>
|
||||
<AppInput v-model="createForm.vastTagUrl" placeholder="https://..." />
|
||||
<label class="text-sm font-medium text-foreground/70">VAST URL</label>
|
||||
<AdminInput v-model="createForm.vastTagUrl" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad format</label>
|
||||
<select v-model="createForm.adFormat" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Ad format</label>
|
||||
<AdminSelect v-model="createForm.adFormat">
|
||||
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Duration</label>
|
||||
<AdminInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" />
|
||||
Default
|
||||
</label>
|
||||
@@ -477,36 +479,36 @@ onMounted(loadTemplates);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="editForm.userId" />
|
||||
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
|
||||
<AdminInput v-model="editForm.userId" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="editForm.name" />
|
||||
<label class="text-sm font-medium text-foreground/70">Name</label>
|
||||
<AdminInput v-model="editForm.name" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
<label class="text-sm font-medium text-foreground/70">Description</label>
|
||||
<AdminTextarea v-model="editForm.description" rows="3" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">VAST URL</label>
|
||||
<AppInput v-model="editForm.vastTagUrl" />
|
||||
<label class="text-sm font-medium text-foreground/70">VAST URL</label>
|
||||
<AdminInput v-model="editForm.vastTagUrl" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad format</label>
|
||||
<select v-model="editForm.adFormat" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Ad format</label>
|
||||
<AdminSelect v-model="editForm.adFormat">
|
||||
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Duration</label>
|
||||
<AdminInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" />
|
||||
Default
|
||||
</label>
|
||||
@@ -523,7 +525,7 @@ onMounted(loadTemplates);
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete ad template" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Delete ad template <span class="font-medium">{{ selectedRow?.name || 'this template' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -2,13 +2,15 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import AdminTable from "./components/AdminTable.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import type { ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
|
||||
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
|
||||
@@ -164,7 +166,7 @@ const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
@@ -221,6 +223,19 @@ const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: 'Workers',
|
||||
badge: loading.value ? 'Syncing agent fleet' : `${rows.value.length} agents tracked`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
onClick: loadAgents,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic !== "picpic/events") return;
|
||||
|
||||
@@ -265,22 +280,21 @@ onMounted(loadAgents);
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||
|
||||
<SettingsSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" variant="ghost" @click="loadAgents">Refresh</AppButton>
|
||||
</template>
|
||||
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
|
||||
<AdminSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="8" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
<AdminTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -296,8 +310,8 @@ onMounted(loadAgents);
|
||||
<p class="text-xs text-foreground/40">Workers will appear here when they register with the admin runtime.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</SettingsSectionCard>
|
||||
</AdminTable>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
@@ -310,18 +324,18 @@ onMounted(loadAgents);
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">CPU</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">CPU</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ formatCpu(selectedRow.cpu) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">RAM</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">RAM</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ formatRam(selectedRow.ram) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -338,7 +352,7 @@ onMounted(loadAgents);
|
||||
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Send restart command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -353,7 +367,7 @@ onMounted(loadAgents);
|
||||
<AppDialog v-model:visible="updateOpen" title="Update agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Send update command to <span class="font-medium">{{ selectedRow?.name || 'this agent' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -2,12 +2,14 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import AdminTextarea from "./components/AdminTextarea.vue";
|
||||
import AdminTable from "./components/AdminTable.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
@@ -16,10 +18,13 @@ type ListJobsResponse = Awaited<ReturnType<typeof rpcClient.listAdminJobs>>;
|
||||
type AdminJobRow = NonNullable<ListJobsResponse["jobs"]>[number];
|
||||
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminJobRow[]>([]);
|
||||
const nextCursor = ref<string | undefined>(undefined);
|
||||
const hasMore = ref(false);
|
||||
const selectedRow = ref<AdminJobRow | null>(null);
|
||||
const selectedLogs = ref("");
|
||||
const activeAgentFilter = ref("");
|
||||
@@ -122,11 +127,13 @@ const loadJobs = async () => {
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminJobs({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
cursor: undefined,
|
||||
pageSize: 50,
|
||||
agentId: appliedAgentFilter.value.trim() || undefined,
|
||||
});
|
||||
rows.value = response.jobs ?? [];
|
||||
nextCursor.value = response.nextCursor || undefined;
|
||||
hasMore.value = Boolean(response.hasMore);
|
||||
syncSelectedRow();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin jobs";
|
||||
@@ -135,8 +142,31 @@ const loadJobs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreJobs = async () => {
|
||||
if (loading.value || loadingMore.value || !hasMore.value || !nextCursor.value) return;
|
||||
loadingMore.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminJobs({
|
||||
cursor: nextCursor.value,
|
||||
pageSize: 50,
|
||||
agentId: appliedAgentFilter.value.trim() || undefined,
|
||||
});
|
||||
rows.value = [...rows.value, ...(response.jobs ?? [])];
|
||||
nextCursor.value = response.nextCursor || undefined;
|
||||
hasMore.value = Boolean(response.hasMore);
|
||||
syncSelectedRow();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to load more jobs";
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = async () => {
|
||||
appliedAgentFilter.value = activeAgentFilter.value;
|
||||
nextCursor.value = undefined;
|
||||
hasMore.value = false;
|
||||
await loadJobs();
|
||||
};
|
||||
|
||||
@@ -272,7 +302,7 @@ const columns = computed<ColumnDef<AdminJobRow>[]>(() => [
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
@@ -380,16 +410,17 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
});
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: "Runtime",
|
||||
badge: `${rows.value.length} jobs loaded`,
|
||||
eyebrow: 'Runtime',
|
||||
badge: loading.value ? 'Polling queue state' : `${rows.value.length} jobs loaded`,
|
||||
actions: [
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
label: 'Refresh',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
onClick: loadJobs,
|
||||
},
|
||||
{
|
||||
label: "Create job",
|
||||
label: 'Create job',
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
@@ -405,37 +436,39 @@ onMounted(loadJobs);
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SettingsSectionCard title="Filters" description="Find jobs by name or status, then narrow the list by assigned agent if needed." bodyClass="p-5">
|
||||
<AdminSectionCard title="Filters" description="Find jobs by name or status, then narrow the list by assigned agent if needed." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[220px_minmax(0,1fr)_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Assigned agent</label>
|
||||
<AppInput v-model="activeAgentFilter" placeholder="Optional agent reference" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">Assigned agent</label>
|
||||
<AdminInput v-model="activeAgentFilter" placeholder="Optional agent reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search by job name or status" />
|
||||
<label class="text-xs font-medium text-foreground/60">Search</label>
|
||||
<AdminInput v-model="search" placeholder="Search by job name or status" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="activeAgentFilter = ''; appliedAgentFilter = ''; search = ''; loadJobs()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="ghost" @click="activeAgentFilter = ''; appliedAgentFilter = ''; search = ''; nextCursor = undefined; hasMore = false; loadJobs()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||
|
||||
<SettingsSectionCard v-else title="Jobs" description="Current queue state and operator actions." bodyClass="">
|
||||
<AdminSectionCard v-else title="Jobs" description="Current queue state and operator actions." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
<div v-else>
|
||||
<AdminTable
|
||||
:data="filteredRows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.name || ''"
|
||||
@@ -450,8 +483,14 @@ onMounted(loadJobs);
|
||||
<p class="text-xs text-foreground/40">Try a broader job name or clear the agent filter.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</SettingsSectionCard>
|
||||
</AdminTable>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||
<p class="text-xs text-foreground/50">{{ hasMore ? 'More jobs available.' : 'Showing the latest jobs.' }}</p>
|
||||
<AppButton v-if="hasMore" size="sm" variant="secondary" :loading="loadingMore" @click="loadMoreJobs">Load more</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
@@ -464,13 +503,13 @@ onMounted(loadJobs);
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-950 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-400">
|
||||
<div class="flex items-center justify-between gap-2 text-[11px] text-slate-400">
|
||||
<span>Live logs</span>
|
||||
<button type="button" class="text-slate-300 transition hover:text-white" @click="selectedRow && openLogsDialog(selectedRow)">Open full logs</button>
|
||||
</div>
|
||||
@@ -491,32 +530,32 @@ onMounted(loadJobs);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Command</label>
|
||||
<textarea v-model="createForm.command" rows="4" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="ffmpeg -i ..." />
|
||||
<label class="text-sm font-medium text-foreground/70">Command</label>
|
||||
<AdminTextarea v-model="createForm.command" rows="4" placeholder="ffmpeg -i ..." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Image</label>
|
||||
<AppInput v-model="createForm.image" placeholder="alpine" />
|
||||
<label class="text-sm font-medium text-foreground/70">Image</label>
|
||||
<AdminInput v-model="createForm.image" placeholder="alpine" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
|
||||
<AdminInput v-model="createForm.userId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Display name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Display name</label>
|
||||
<AdminInput v-model="createForm.name" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Priority</label>
|
||||
<AppInput v-model="createForm.priority" type="number" />
|
||||
<label class="text-sm font-medium text-foreground/70">Priority</label>
|
||||
<AdminInput v-model="createForm.priority" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Time limit</label>
|
||||
<AppInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
|
||||
<label class="text-sm font-medium text-foreground/70">Time limit</label>
|
||||
<AdminInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Environment</label>
|
||||
<textarea v-model="createForm.envText" rows="5" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="KEY=value per line" />
|
||||
<label class="text-sm font-medium text-foreground/70">Environment</label>
|
||||
<AdminTextarea v-model="createForm.envText" rows="5" placeholder="KEY=value per line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,7 +584,7 @@ onMounted(loadJobs);
|
||||
<AppDialog v-model:visible="cancelOpen" title="Cancel job" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Cancel <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -560,7 +599,7 @@ onMounted(loadJobs);
|
||||
<AppDialog v-model:visible="retryOpen" title="Retry job" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Retry <span class="font-medium">{{ selectedRow?.name || 'this job' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
140
src/routes/settings/admin/Layout.vue
Normal file
140
src/routes/settings/admin/Layout.vue
Normal 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>
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -23,6 +25,20 @@ const summary = computed(() => [
|
||||
|
||||
const activeChannel = computed(() => activeJobId.value ? `picpic/logs/${activeJobId.value}` : "No active stream");
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: 'Diagnostics',
|
||||
badge: activeJobId.value ? `Streaming ${activeJobId.value}` : 'Awaiting job selection',
|
||||
actions: [
|
||||
{
|
||||
label: 'Fetch logs',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
disabled: !jobId.trim(),
|
||||
onClick: loadLogs,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!jobId.value.trim()) return;
|
||||
loading.value = true;
|
||||
@@ -65,18 +81,20 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 truncate text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SettingsSectionCard title="Log session" description="Load persisted logs once, then keep appending live lines for the same job." bodyClass="p-5">
|
||||
<AdminSectionCard title="Log session" description="Load persisted logs once, then keep appending live lines for the same job." bodyClass="p-5">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Job ID</label>
|
||||
<AppInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
|
||||
<label class="text-xs font-medium text-foreground/60">Job ID</label>
|
||||
<AdminInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="ghost" @click="clearLogs">Reset</AppButton>
|
||||
@@ -86,24 +104,30 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Current channel</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">Current channel</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-foreground">{{ activeChannel }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm leading-6 text-foreground/70">
|
||||
Persisted logs are loaded once from gRPC, then appended live from MQTT frames for the same job.
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard title="Runtime output" :description="activeJobId || 'idle'" bodyClass="p-5">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-950 p-4">
|
||||
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre>
|
||||
<AdminSectionCard title="Runtime output" :description="activeJobId || 'idle'" bodyClass="p-5">
|
||||
<div class="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-4 py-3">
|
||||
<div class="text-[11px] font-medium text-slate-400">Live stream buffer</div>
|
||||
<div class="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-300">
|
||||
{{ activeJobId ? 'Connected' : 'Idle' }}
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words px-4 py-4 font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre>
|
||||
</div>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
@@ -2,13 +2,15 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import AdminSelect from "./components/AdminSelect.vue";
|
||||
import AdminTable from "./components/AdminTable.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import BillingPlansSection from "@/routes/settings/Billing/components/BillingPlansSection.vue";
|
||||
import type { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
@@ -241,24 +243,25 @@ const statusBadgeClass = (status?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// useAdminPageHeader(() => ({
|
||||
// eyebrow: "Finance",
|
||||
// badge: `${total.value} total payments`,
|
||||
// actions: [
|
||||
// {
|
||||
// label: "Refresh",
|
||||
// variant: "secondary",
|
||||
// onClick: loadPayments,
|
||||
// },
|
||||
// {
|
||||
// label: "Create payment",
|
||||
// onClick: () => {
|
||||
// actionError.value = null;
|
||||
// createOpen.value = true;
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// }));
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: 'Finance',
|
||||
badge: loading.value ? 'Syncing payment records' : `${total.value} payment records`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
onClick: loadPayments,
|
||||
},
|
||||
{
|
||||
label: 'Create payment',
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
|
||||
{
|
||||
@@ -309,7 +312,7 @@ const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
@@ -355,44 +358,42 @@ onMounted(() => {
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SettingsSectionCard title="Filters" description="Filter payments by user reference and status." bodyClass="p-5">
|
||||
<AdminSectionCard title="Filters" description="Filter payments by user reference and status." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[220px_220px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">User reference</label>
|
||||
<AppInput v-model="userFilter" placeholder="Optional user reference" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">User reference</label>
|
||||
<AdminInput v-model="userFilter" placeholder="Optional user reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Status</label>
|
||||
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-xs font-medium text-foreground/60">Status</label>
|
||||
<AdminSelect v-model="statusFilter">
|
||||
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="userFilter = ''; appliedUserFilter = ''; statusFilter = ''; loadPayments()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" variant="ghost" @click="loadPayments">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="createOpen = true">Create payment</AppButton>
|
||||
</template>
|
||||
<AdminSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
<AdminTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -408,16 +409,16 @@ onMounted(() => {
|
||||
<p class="text-xs text-foreground/40">Try a broader user reference or clear the status filter.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</AdminTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="text-xs text-foreground/55">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
|
||||
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
@@ -430,7 +431,7 @@ onMounted(() => {
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -449,22 +450,22 @@ onMounted(() => {
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">User ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
<label class="text-sm font-medium text-foreground/70">User ID</label>
|
||||
<AdminInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Term months</label>
|
||||
<AppInput v-model="createForm.termMonths" type="number" min="1" />
|
||||
<label class="text-sm font-medium text-foreground/70">Term months</label>
|
||||
<AdminInput v-model="createForm.termMonths" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Payment method</label>
|
||||
<select v-model="createForm.paymentMethod" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Payment method</label>
|
||||
<AdminSelect v-model="createForm.paymentMethod">
|
||||
<option v-for="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Topup amount</label>
|
||||
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Topup amount</label>
|
||||
<AdminInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -499,10 +500,10 @@ onMounted(() => {
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="statusForm.status" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Status</label>
|
||||
<AdminSelect v-model="statusForm.status">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -2,11 +2,15 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import AdminSelect from "./components/AdminSelect.vue";
|
||||
import AdminTextarea from "./components/AdminTextarea.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
|
||||
type ListPlansResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlans>>;
|
||||
type AdminPlanRow = NonNullable<ListPlansResponse["plans"]>[number];
|
||||
@@ -59,7 +63,7 @@ const summary = computed(() => [
|
||||
{ label: "Plans", value: rows.value.length },
|
||||
{ label: "Active", value: rows.value.filter((row) => row.isActive).length },
|
||||
{ label: "Highest price", value: rows.value.reduce((max, row) => Math.max(max, Number(row.price ?? 0)), 0) },
|
||||
{ label: "Avg storage", value: Math.round(rows.value.reduce((sum, row) => sum + Number(row.storageLimit ?? 0), 0) / Math.max(rows.value.length, 1)) },
|
||||
{ label: "Avg storage", value: formatBytes(Math.round(rows.value.reduce((sum, row) => sum + Number(row.storageLimit ?? 0), 0) / Math.max(rows.value.length, 1))) },
|
||||
]);
|
||||
const selectedMeta = computed(() => {
|
||||
if (!selectedRow.value) return [];
|
||||
@@ -208,6 +212,7 @@ useAdminPageHeader(() => ({
|
||||
{
|
||||
label: "Refresh",
|
||||
variant: "secondary",
|
||||
loading: loading.value,
|
||||
onClick: loadPlans,
|
||||
},
|
||||
{
|
||||
@@ -227,64 +232,70 @@ onMounted(loadPlans);
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||
<div v-else-if="loading" class="rounded-lg border border-border bg-muted/20 px-4 py-10 text-center text-foreground/60">Loading plans...</div>
|
||||
<div v-else-if="rows.length === 0" class="rounded-lg border border-border bg-muted/20 px-4 py-10 text-center text-foreground/60">No plans found.</div>
|
||||
<div v-else class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<SettingsSectionCard
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
:title="row.name"
|
||||
:description="row.description || 'No description'"
|
||||
bodyClass="p-5"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-2 gap-3 flex-1 text-sm text-foreground/70">
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Price</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.price }}</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3" v-else>
|
||||
<div v-for="row in rows" :key="row.id" class="flex flex-col max-w-sm w-full bg-white rounded-2xl border border-gray-200 overflow-hidden transition-all hover:shadow-xl">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ row.name }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ row.description || 'No description' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Cycle</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.cycle }}</div>
|
||||
<span v-if="row.isActive" class="px-3 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full">Active</span>
|
||||
<span v-else class="px-3 py-1 text-xs font-semibold text-red-700 bg-red-100 rounded-full">Inactive</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Storage</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.storageLimit }}</div>
|
||||
<div class="mt-4">
|
||||
<span class="text-3xl font-extrabold text-gray-900">${{ row.price }}</span>
|
||||
<span class="text-gray-500 text-sm">/{{ row.cycle }}</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Uploads</div>
|
||||
<div class="mt-1 font-semibold text-foreground">{{ row.uploadLimit }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="row.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-border bg-muted/40 text-foreground/70'">
|
||||
{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Features</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-foreground/70">
|
||||
<div class="p-6 bg-gray-50/50 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="Check-circle" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">Storage: {{ formatBytes(row.storageLimit) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="Check-circle" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">Uploads: {{ row.uploadLimit }}</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="text-xs font-medium text-gray-500">Features</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-700">
|
||||
<li v-for="feature in row.features || []" :key="feature">• {{ feature }}</li>
|
||||
<li v-if="!(row.features || []).length" class="text-foreground/50">No features listed.</li>
|
||||
<li v-if="!(row.features || []).length" class="text-gray-500">No features listed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="p-4 bg-white flex gap-2 mt-a">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">
|
||||
Edit Plan
|
||||
</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="openDetailDialog(row)">Details</AppButton>
|
||||
<AppButton size="sm" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="openMenuDialog(row)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="Circle-dots-horizontal" />
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
@@ -297,12 +308,12 @@ onMounted(loadPlans);
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Features</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">Features</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-foreground/70">
|
||||
<li v-for="feature in selectedRow.features || []" :key="feature">• {{ feature }}</li>
|
||||
<li v-if="!(selectedRow.features || []).length" class="text-foreground/50">No features listed.</li>
|
||||
@@ -323,36 +334,36 @@ onMounted(loadPlans);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Starter" />
|
||||
<label class="text-sm font-medium text-foreground/70">Name</label>
|
||||
<AdminInput v-model="createForm.name" placeholder="Starter" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Description</label>
|
||||
<AdminTextarea v-model="createForm.description" :rows="3" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Features</label>
|
||||
<textarea v-model="createForm.featuresText" rows="4" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="One feature per line" />
|
||||
<label class="text-sm font-medium text-foreground/70">Features</label>
|
||||
<AdminTextarea v-model="createForm.featuresText" :rows="4" placeholder="One feature per line" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Price</label>
|
||||
<AppInput v-model="createForm.price" type="number" min="0" step="0.01" />
|
||||
<label class="text-sm font-medium text-foreground/70">Price</label>
|
||||
<AdminInput v-model="createForm.price" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Cycle</label>
|
||||
<select v-model="createForm.cycle" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Cycle</label>
|
||||
<AdminSelect v-model="createForm.cycle">
|
||||
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Storage limit</label>
|
||||
<AppInput v-model="createForm.storageLimit" type="number" min="1" />
|
||||
<label class="text-sm font-medium text-foreground/70">Storage limit</label>
|
||||
<AdminInput v-model="createForm.storageLimit" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Upload limit</label>
|
||||
<AppInput v-model="createForm.uploadLimit" type="number" min="1" />
|
||||
<label class="text-sm font-medium text-foreground/70">Upload limit</label>
|
||||
<AdminInput v-model="createForm.uploadLimit" type="number" min="1" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70 md:col-span-2">
|
||||
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
@@ -371,36 +382,36 @@ onMounted(loadPlans);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="editForm.name" />
|
||||
<label class="text-sm font-medium text-foreground/70">Name</label>
|
||||
<AdminInput v-model="editForm.name" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
<label class="text-sm font-medium text-foreground/70">Description</label>
|
||||
<AdminTextarea v-model="editForm.description" :rows="3" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Features</label>
|
||||
<textarea v-model="editForm.featuresText" rows="4" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
<label class="text-sm font-medium text-foreground/70">Features</label>
|
||||
<AdminTextarea v-model="editForm.featuresText" :rows="4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Price</label>
|
||||
<AppInput v-model="editForm.price" type="number" min="0" step="0.01" />
|
||||
<label class="text-sm font-medium text-foreground/70">Price</label>
|
||||
<AdminInput v-model="editForm.price" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Cycle</label>
|
||||
<select v-model="editForm.cycle" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Cycle</label>
|
||||
<AdminSelect v-model="editForm.cycle">
|
||||
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Storage limit</label>
|
||||
<AppInput v-model="editForm.storageLimit" type="number" min="1" />
|
||||
<label class="text-sm font-medium text-foreground/70">Storage limit</label>
|
||||
<AdminInput v-model="editForm.storageLimit" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Upload limit</label>
|
||||
<AppInput v-model="editForm.uploadLimit" type="number" min="1" />
|
||||
<label class="text-sm font-medium text-foreground/70">Upload limit</label>
|
||||
<AdminInput v-model="editForm.uploadLimit" type="number" min="1" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70 md:col-span-2">
|
||||
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
@@ -417,7 +428,7 @@ onMounted(loadPlans);
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete plan" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || 'this plan' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
566
src/routes/settings/admin/PlayerConfigs.vue
Normal file
566
src/routes/settings/admin/PlayerConfigs.vue
Normal 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>
|
||||
@@ -2,18 +2,22 @@
|
||||
import { client, client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import AdminSelect from "./components/AdminSelect.vue";
|
||||
import AdminTable from "./components/AdminTable.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import AdminUserFormFields from "./components/AdminUserFormFields.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
import AsyncSelect from "@/components/ui/AsyncSelect.vue";
|
||||
|
||||
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
|
||||
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
|
||||
type GetAdminUserResponse = Awaited<ReturnType<typeof rpcClient.getAdminUser>>;
|
||||
type AdminUserDetail = NonNullable<GetAdminUserResponse["user"]>;
|
||||
|
||||
const roleOptions = ["USER", "ADMIN"] as const;
|
||||
const roleFilterOptions = ["", ...roleOptions] as const;
|
||||
@@ -27,6 +31,7 @@ const total = ref(0);
|
||||
const limit = ref(12);
|
||||
const page = ref(1);
|
||||
const selectedRow = ref<AdminUserRow | null>(null);
|
||||
const selectedDetail = ref<AdminUserDetail | null>(null);
|
||||
const search = ref("");
|
||||
const appliedSearch = ref("");
|
||||
const roleFilter = ref<(typeof roleFilterOptions)[number]>("");
|
||||
@@ -36,6 +41,7 @@ const detailOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const roleOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const referralOpen = ref(false);
|
||||
|
||||
const createForm = reactive({
|
||||
email: "",
|
||||
@@ -59,18 +65,32 @@ const roleForm = reactive({
|
||||
role: "USER",
|
||||
});
|
||||
|
||||
const referralForm = reactive({
|
||||
id: "",
|
||||
refUsername: "",
|
||||
clearReferrer: false,
|
||||
referralEligible: true,
|
||||
rewardPercent: "",
|
||||
clearRewardPercent: false,
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim());
|
||||
const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.role.trim());
|
||||
const canUpdateReferral = computed(() => referralForm.id.trim());
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
|
||||
const selectedMeta = computed(() => {
|
||||
if (!selectedRow.value) return [];
|
||||
if (!selectedDetail.value?.user && !selectedRow.value) return [];
|
||||
const user = selectedDetail.value?.user || selectedRow.value;
|
||||
const referral = selectedDetail.value?.referral;
|
||||
return [
|
||||
{ label: "Role", value: selectedRow.value.role || "USER" },
|
||||
{ label: "Plan", value: selectedRow.value.planName || selectedRow.value.planId || "Free" },
|
||||
{ label: "Videos", value: String(selectedRow.value.videoCount ?? 0) },
|
||||
{ label: "Wallet", value: String(selectedRow.value.walletBalance ?? 0) },
|
||||
{ label: "Created", value: formatDate(selectedRow.value.createdAt) },
|
||||
{ label: "Role", value: user?.role || "USER" },
|
||||
{ label: "Plan", value: user?.planName || user?.planId || "Free" },
|
||||
{ label: "Videos", value: String(user?.videoCount ?? 0) },
|
||||
{ label: "Wallet", value: String(user?.walletBalance ?? 0) },
|
||||
{ label: "Referrer", value: referral?.referrer?.username ? `@${referral.referrer.username}` : referral?.referrer?.email || "—" },
|
||||
{ label: "Reward", value: referral?.rewardGranted ? `${referral.rewardAmount ?? 0} USD` : "Pending / none" },
|
||||
{ label: "Created", value: formatDate(user?.createdAt) },
|
||||
];
|
||||
});
|
||||
|
||||
@@ -89,6 +109,11 @@ const normalizeOptional = (value: string) => {
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const planOptionsLoader = () =>
|
||||
client.listPlans().then((plans) =>
|
||||
(plans?.plans || []).map((plan) => ({ label: plan.name!, value: plan.id! })),
|
||||
);
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.email = "";
|
||||
createForm.username = "";
|
||||
@@ -140,10 +165,22 @@ const applyFilters = async () => {
|
||||
await loadUsers();
|
||||
};
|
||||
|
||||
const openDetailDialog = (row: AdminUserRow) => {
|
||||
const loadUserDetail = async (userId: string) => {
|
||||
const response = await rpcClient.getAdminUser({ id: userId });
|
||||
selectedDetail.value = response.user ?? null;
|
||||
return selectedDetail.value;
|
||||
};
|
||||
|
||||
const openDetailDialog = async (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
selectedDetail.value = null;
|
||||
actionError.value = null;
|
||||
detailOpen.value = true;
|
||||
try {
|
||||
await loadUserDetail(row.id || '');
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || 'Failed to load user details';
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminUserRow) => {
|
||||
@@ -172,6 +209,24 @@ const openDeleteDialog = (row: AdminUserRow) => {
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const openReferralDialog = async (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
referralOpen.value = true;
|
||||
referralForm.id = row.id || '';
|
||||
referralForm.refUsername = '';
|
||||
referralForm.clearReferrer = false;
|
||||
referralForm.rewardPercent = '';
|
||||
referralForm.clearRewardPercent = false;
|
||||
try {
|
||||
const detail = await loadUserDetail(row.id || '');
|
||||
referralForm.referralEligible = detail?.referral?.referralEligible ?? true;
|
||||
referralForm.rewardPercent = detail?.referral?.rewardOverridePercent != null ? String(detail.referral.rewardOverridePercent) : '';
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || 'Failed to load referral settings';
|
||||
}
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
@@ -242,6 +297,7 @@ const submitDelete = async () => {
|
||||
await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
selectedDetail.value = null;
|
||||
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
@@ -251,6 +307,30 @@ const submitDelete = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitReferral = async () => {
|
||||
if (!canUpdateReferral.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
const rewardPercent = referralForm.rewardPercent.trim();
|
||||
const response = await rpcClient.updateAdminUserReferralSettings({
|
||||
id: referralForm.id,
|
||||
refUsername: referralForm.clearReferrer ? undefined : normalizeOptional(referralForm.refUsername),
|
||||
clearReferrer: referralForm.clearReferrer || undefined,
|
||||
referralEligible: referralForm.referralEligible,
|
||||
referralRewardBps: referralForm.clearRewardPercent || rewardPercent === '' ? undefined : Math.round(Number(rewardPercent) * 100),
|
||||
clearReferralRewardBps: referralForm.clearRewardPercent || undefined,
|
||||
});
|
||||
selectedDetail.value = response.user ?? null;
|
||||
referralOpen.value = false;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || 'Failed to update referral settings';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const previousPage = async () => {
|
||||
if (page.value <= 1) return;
|
||||
page.value -= 1;
|
||||
@@ -305,7 +385,7 @@ const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
|
||||
cell: ({ row }) => h(
|
||||
"span",
|
||||
{
|
||||
class: `inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${roleBadgeClass(row.original.role)}`,
|
||||
class: `inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ${roleBadgeClass(row.original.role)}`,
|
||||
},
|
||||
row.original.role || "USER"
|
||||
),
|
||||
@@ -351,6 +431,7 @@ const columns = computed<ColumnDef<AdminUserRow>[]>(() => [
|
||||
cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [
|
||||
h(AppButton, { size: "sm", variant: "secondary", onClick: () => openEditDialog(row.original) }, { default: () => "Edit" }),
|
||||
h(AppButton, { size: "sm", variant: "ghost", onClick: () => openRoleDialog(row.original) }, { default: () => "Role" }),
|
||||
h(AppButton, { size: "sm", variant: "ghost", onClick: () => openReferralDialog(row.original) }, { default: () => "Referral" }),
|
||||
h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }),
|
||||
]),
|
||||
meta: {
|
||||
@@ -365,6 +446,26 @@ watch(roleFilter, async () => {
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: 'Access',
|
||||
badge: loading.value ? 'Syncing user directory' : `${total.value} records loaded`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
onClick: loadUsers,
|
||||
},
|
||||
{
|
||||
label: 'Create user',
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onMounted(loadUsers);
|
||||
</script>
|
||||
|
||||
@@ -372,29 +473,27 @@ onMounted(loadUsers);
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
class="rounded-lg border border-border bg-muted/20 p-4"
|
||||
>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SettingsSectionCard title="Filters" description="Find users by email, username or role." bodyClass="p-5">
|
||||
<AdminSectionCard title="Filters" description="Find users by email, username or role." bodyClass="p-5">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px] lg:min-w-[560px]">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.16em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search by email or username" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">Search</label>
|
||||
<AdminInput v-model="search" placeholder="Search by email or username" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.16em] text-foreground/50">Role filter</label>
|
||||
<select v-model="roleFilter" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-xs font-medium text-foreground/60">Role filter</label>
|
||||
<AdminSelect v-model="roleFilter">
|
||||
<option v-for="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -402,21 +501,17 @@ onMounted(loadUsers);
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" variant="ghost" @click="loadUsers">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="createOpen = true; actionError = null">Create user</AppButton>
|
||||
</template>
|
||||
<AdminSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
|
||||
|
||||
<template v-else>
|
||||
<BaseTable
|
||||
<AdminTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id || row.email || ''"
|
||||
@@ -431,10 +526,10 @@ onMounted(loadUsers);
|
||||
<p class="text-xs text-foreground/40">Try clearing the search term or switching the selected role.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</AdminTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">
|
||||
<div class="text-xs text-foreground/55">
|
||||
Page {{ page }} of {{ totalPages }} · {{ total }} records
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -443,37 +538,23 @@ onMounted(loadUsers);
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Email</label>
|
||||
<AppInput v-model="createForm.email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||
<AppInput v-model="createForm.username" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="createForm.role" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Password</label>
|
||||
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan</label>
|
||||
<AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
|
||||
</div>
|
||||
</div>
|
||||
<AdminUserFormFields
|
||||
mode="create"
|
||||
v-model:email="createForm.email"
|
||||
v-model:username="createForm.username"
|
||||
v-model:role="createForm.role"
|
||||
v-model:password="createForm.password"
|
||||
v-model:plan-id="createForm.planId"
|
||||
:role-options="roleOptions"
|
||||
:load-plan-options="planOptionsLoader"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
@@ -492,7 +573,7 @@ onMounted(loadUsers);
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,6 +581,7 @@ onMounted(loadUsers);
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="detailOpen = false">Close</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openReferralDialog(selectedRow)">Referral</AppButton>
|
||||
<AppButton size="sm" @click="detailOpen = false; selectedRow && openEditDialog(selectedRow)">Edit</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -508,34 +590,16 @@ onMounted(loadUsers);
|
||||
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Email</label>
|
||||
<AppInput v-model="editForm.email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||
<AppInput v-model="editForm.username" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="editForm.role" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Reset password</label>
|
||||
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan</label>
|
||||
<AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
|
||||
<!-- <select v-model="editForm.planId" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="plan in selectedRow?.availablePlans || []" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
|
||||
</select> -->
|
||||
<!-- <AppInput v-model="editForm.planId" placeholder="Optional" /> -->
|
||||
</div>
|
||||
</div>
|
||||
<AdminUserFormFields
|
||||
mode="edit"
|
||||
v-model:email="editForm.email"
|
||||
v-model:username="editForm.username"
|
||||
v-model:role="editForm.role"
|
||||
v-model:password="editForm.password"
|
||||
v-model:plan-id="editForm.planId"
|
||||
:role-options="roleOptions"
|
||||
:load-plan-options="planOptionsLoader"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
@@ -549,10 +613,10 @@ onMounted(loadUsers);
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="roleForm.role" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Role</label>
|
||||
<AdminSelect v-model="roleForm.role">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -563,10 +627,42 @@ onMounted(loadUsers);
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="referralOpen" title="Referral settings" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground/70">Referrer username</label>
|
||||
<AdminInput v-model="referralForm.refUsername" placeholder="alice" :disabled="referralForm.clearReferrer" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="referralForm.clearReferrer" type="checkbox" />
|
||||
Clear current referrer
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="referralForm.referralEligible" type="checkbox" />
|
||||
Referral eligible
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground/70">Reward override percent</label>
|
||||
<AdminInput v-model="referralForm.rewardPercent" placeholder="5" :disabled="referralForm.clearRewardPercent" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<input v-model="referralForm.clearRewardPercent" type="checkbox" />
|
||||
Clear reward override
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateReferral" @click="submitReferral">Save referral</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete user" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Delete <span class="font-medium">{{ selectedRow?.email || selectedRow?.id }}</span> and related data.
|
||||
</p>
|
||||
</div>
|
||||
@@ -2,11 +2,14 @@
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import AdminInput from "./components/AdminInput.vue";
|
||||
import AdminSelect from "./components/AdminSelect.vue";
|
||||
import AdminTextarea from "./components/AdminTextarea.vue";
|
||||
import AdminTable from "./components/AdminTable.vue";
|
||||
import AdminSectionCard from "./components/AdminSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
import AdminMetricCard from "./components/AdminMetricCard.vue";
|
||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||
@@ -319,7 +322,7 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
|
||||
header: "Status",
|
||||
accessorFn: row => row.status || "",
|
||||
cell: ({ row }) => h("span", {
|
||||
class: ["inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]", statusBadgeClass(row.original.status)],
|
||||
class: ["inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ", statusBadgeClass(row.original.status)],
|
||||
}, row.original.status || "UNKNOWN"),
|
||||
meta: {
|
||||
headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50",
|
||||
@@ -371,24 +374,25 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
// useAdminPageHeader(() => ({
|
||||
// eyebrow: "Media",
|
||||
// badge: `${total.value} total videos`,
|
||||
// actions: [
|
||||
// {
|
||||
// label: "Refresh",
|
||||
// variant: "secondary",
|
||||
// onClick: loadVideos,
|
||||
// },
|
||||
// {
|
||||
// label: "Create video",
|
||||
// onClick: () => {
|
||||
// actionError.value = null;
|
||||
// createOpen.value = true;
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// }));
|
||||
useAdminPageHeader(() => ({
|
||||
eyebrow: 'Media',
|
||||
badge: loading.value ? 'Syncing media inventory' : `${total.value} total videos`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
variant: 'secondary',
|
||||
loading: loading.value,
|
||||
onClick: loadVideos,
|
||||
},
|
||||
{
|
||||
label: 'Create video',
|
||||
onClick: () => {
|
||||
actionError.value = null;
|
||||
createOpen.value = true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
watch(statusFilter, async () => {
|
||||
page.value = 1;
|
||||
@@ -402,48 +406,46 @@ onMounted(loadVideos);
|
||||
<AdminSectionShell>
|
||||
|
||||
<template #stats>
|
||||
<div v-for="item in summary" :key="item.label" class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
<AdminMetricCard
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SettingsSectionCard title="Filters" description="Search videos by title and narrow by owner reference or status." bodyClass="p-5">
|
||||
<AdminSectionCard title="Filters" description="Search videos by title and narrow by owner reference or status." bodyClass="p-5">
|
||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_220px_180px_auto] xl:items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Search</label>
|
||||
<AppInput v-model="search" placeholder="Search by title" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">Search</label>
|
||||
<AdminInput v-model="search" placeholder="Search by title" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Owner reference</label>
|
||||
<AppInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
|
||||
<label class="text-xs font-medium text-foreground/60">Owner reference</label>
|
||||
<AdminInput v-model="ownerFilter" placeholder="Optional owner reference" @enter="applyFilters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/50">Status</label>
|
||||
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-xs font-medium text-foreground/60">Status</label>
|
||||
<AdminSelect v-model="statusFilter">
|
||||
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 xl:justify-end">
|
||||
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; statusFilter = ''; loadVideos()">Reset</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" variant="ghost" @click="loadVideos">Refresh</AppButton>
|
||||
<AppButton size="sm" @click="createOpen = true; actionError = null">Create video</AppButton>
|
||||
</template>
|
||||
<AdminSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
|
||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
<AdminTable
|
||||
v-else
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -459,16 +461,16 @@ onMounted(loadVideos);
|
||||
<p class="text-xs text-foreground/40">Try a broader title or clear the owner and status filters.</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</AdminTable>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border bg-muted/20 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-foreground/50">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="text-xs text-foreground/55">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
|
||||
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</AdminSectionCard>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
@@ -480,12 +482,12 @@ onMounted(loadVideos);
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div v-for="item in selectedMeta" :key="item.label" class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">{{ item.label }}</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-foreground">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.16em] text-foreground/50">Source URL</div>
|
||||
<div class="text-[11px] font-medium text-foreground/55">Source URL</div>
|
||||
<div class="mt-2 break-all text-sm text-foreground/70">{{ selectedRow.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,42 +505,42 @@ onMounted(loadVideos);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
|
||||
<AdminInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="createForm.status" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Status</label>
|
||||
<AdminSelect v-model="createForm.status">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Title</label>
|
||||
<AppInput v-model="createForm.title" placeholder="Video title" />
|
||||
<label class="text-sm font-medium text-foreground/70">Title</label>
|
||||
<AdminInput v-model="createForm.title" placeholder="Video title" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Video URL</label>
|
||||
<AppInput v-model="createForm.url" placeholder="https://..." />
|
||||
<label class="text-sm font-medium text-foreground/70">Video URL</label>
|
||||
<AdminInput v-model="createForm.url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Description</label>
|
||||
<AdminTextarea v-model="createForm.description" rows="3" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Format</label>
|
||||
<AppInput v-model="createForm.format" placeholder="mp4" />
|
||||
<label class="text-sm font-medium text-foreground/70">Format</label>
|
||||
<AdminInput v-model="createForm.format" placeholder="mp4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
|
||||
<AppInput v-model="createForm.adTemplateId" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Ad template ID</label>
|
||||
<AdminInput v-model="createForm.adTemplateId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Size</label>
|
||||
<AppInput v-model="createForm.size" type="number" placeholder="0" min="0" />
|
||||
<label class="text-sm font-medium text-foreground/70">Size</label>
|
||||
<AdminInput v-model="createForm.size" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
|
||||
<label class="text-sm font-medium text-foreground/70">Duration</label>
|
||||
<AdminInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,42 +557,42 @@ onMounted(loadVideos);
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="editForm.userId" placeholder="user-id" />
|
||||
<label class="text-sm font-medium text-foreground/70">Owner user ID</label>
|
||||
<AdminInput v-model="editForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="editForm.status" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<label class="text-sm font-medium text-foreground/70">Status</label>
|
||||
<AdminSelect v-model="editForm.status">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Title</label>
|
||||
<AppInput v-model="editForm.title" placeholder="Video title" />
|
||||
<label class="text-sm font-medium text-foreground/70">Title</label>
|
||||
<AdminInput v-model="editForm.title" placeholder="Video title" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Video URL</label>
|
||||
<AppInput v-model="editForm.url" placeholder="https://..." />
|
||||
<label class="text-sm font-medium text-foreground/70">Video URL</label>
|
||||
<AdminInput v-model="editForm.url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Description</label>
|
||||
<AdminTextarea v-model="editForm.description" rows="3" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Format</label>
|
||||
<AppInput v-model="editForm.format" placeholder="mp4" />
|
||||
<label class="text-sm font-medium text-foreground/70">Format</label>
|
||||
<AdminInput v-model="editForm.format" placeholder="mp4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
|
||||
<AppInput v-model="editForm.adTemplateId" placeholder="Optional" />
|
||||
<label class="text-sm font-medium text-foreground/70">Ad template ID</label>
|
||||
<AdminInput v-model="editForm.adTemplateId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Size</label>
|
||||
<AppInput v-model="editForm.size" type="number" placeholder="0" min="0" />
|
||||
<label class="text-sm font-medium text-foreground/70">Size</label>
|
||||
<AdminInput v-model="editForm.size" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
|
||||
<label class="text-sm font-medium text-foreground/70">Duration</label>
|
||||
<AdminInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,7 +607,7 @@ onMounted(loadVideos);
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete video" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
<p class="text-sm text-foreground/70">
|
||||
Delete video <span class="font-medium">{{ selectedRow?.title || 'this video' }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
47
src/routes/settings/admin/components/AdminInput.vue
Normal file
47
src/routes/settings/admin/components/AdminInput.vue
Normal 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>
|
||||
46
src/routes/settings/admin/components/AdminMetricCard.vue
Normal file
46
src/routes/settings/admin/components/AdminMetricCard.vue
Normal 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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { computed, h } from 'vue';
|
||||
import AdminTable from './AdminTable.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number | string[];
|
||||
@@ -42,12 +42,10 @@ const tableColumns = computed<ColumnDef<SkeletonRow>[]>(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseTable
|
||||
<AdminTable
|
||||
:data="data"
|
||||
:columns="tableColumns"
|
||||
wrapperClass="border-0 rounded-none bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="animate-pulse border-b border-border hover:bg-transparent"
|
||||
/>
|
||||
</template>
|
||||
45
src/routes/settings/admin/components/AdminSectionCard.vue
Normal file
45
src/routes/settings/admin/components/AdminSectionCard.vue
Normal 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>
|
||||
11
src/routes/settings/admin/components/AdminSectionShell.vue
Normal file
11
src/routes/settings/admin/components/AdminSectionShell.vue
Normal 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>
|
||||
38
src/routes/settings/admin/components/AdminSelect.vue
Normal file
38
src/routes/settings/admin/components/AdminSelect.vue
Normal 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>
|
||||
73
src/routes/settings/admin/components/AdminTable.vue
Normal file
73
src/routes/settings/admin/components/AdminTable.vue
Normal 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>
|
||||
36
src/routes/settings/admin/components/AdminTextarea.vue
Normal file
36
src/routes/settings/admin/components/AdminTextarea.vue
Normal 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>
|
||||
58
src/routes/settings/admin/components/AdminUserFormFields.vue
Normal file
58
src/routes/settings/admin/components/AdminUserFormFields.vue
Normal 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>
|
||||
@@ -3,7 +3,7 @@ import { inject, onBeforeUnmount, reactive, watchEffect, type VNode } from 'vue'
|
||||
export type AdminHeaderAction = {
|
||||
label: string;
|
||||
icon?: string | VNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -35,6 +35,7 @@ export interface UpdateMeRequest {
|
||||
email?: string | undefined;
|
||||
language?: string | undefined;
|
||||
locale?: string | undefined;
|
||||
telegramId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface UpdateMeResponse {
|
||||
@@ -206,7 +207,7 @@ export const GetMeResponse: MessageFns<GetMeResponse> = {
|
||||
};
|
||||
|
||||
function createBaseUpdateMeRequest(): UpdateMeRequest {
|
||||
return { username: undefined, email: undefined, language: undefined, locale: undefined };
|
||||
return { username: undefined, email: undefined, language: undefined, locale: undefined, telegramId: undefined };
|
||||
}
|
||||
|
||||
export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
|
||||
@@ -223,6 +224,9 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
|
||||
if (message.locale !== undefined) {
|
||||
writer.uint32(34).string(message.locale);
|
||||
}
|
||||
if (message.telegramId !== undefined) {
|
||||
writer.uint32(42).string(message.telegramId);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -265,6 +269,14 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
|
||||
message.locale = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.telegramId = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -280,6 +292,11 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
|
||||
email: isSet(object.email) ? globalThis.String(object.email) : undefined,
|
||||
language: isSet(object.language) ? globalThis.String(object.language) : undefined,
|
||||
locale: isSet(object.locale) ? globalThis.String(object.locale) : undefined,
|
||||
telegramId: isSet(object.telegramId)
|
||||
? globalThis.String(object.telegramId)
|
||||
: isSet(object.telegram_id)
|
||||
? globalThis.String(object.telegram_id)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -297,6 +314,9 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
|
||||
if (message.locale !== undefined) {
|
||||
obj.locale = message.locale;
|
||||
}
|
||||
if (message.telegramId !== undefined) {
|
||||
obj.telegramId = message.telegramId;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -309,6 +329,7 @@ export const UpdateMeRequest: MessageFns<UpdateMeRequest> = {
|
||||
message.email = object.email ?? undefined;
|
||||
message.language = object.language ?? undefined;
|
||||
message.locale = object.locale ?? undefined;
|
||||
message.telegramId = object.telegramId ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ export interface RegisterRequest {
|
||||
username?: string | undefined;
|
||||
email?: string | undefined;
|
||||
password?: string | undefined;
|
||||
refUsername?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
@@ -67,6 +68,7 @@ export interface GetGoogleLoginUrlResponse {
|
||||
|
||||
export interface CompleteGoogleLoginRequest {
|
||||
code?: string | undefined;
|
||||
refUsername?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CompleteGoogleLoginResponse {
|
||||
@@ -208,7 +210,7 @@ export const LoginResponse: MessageFns<LoginResponse> = {
|
||||
};
|
||||
|
||||
function createBaseRegisterRequest(): RegisterRequest {
|
||||
return { username: "", email: "", password: "" };
|
||||
return { username: "", email: "", password: "", refUsername: undefined };
|
||||
}
|
||||
|
||||
export const RegisterRequest: MessageFns<RegisterRequest> = {
|
||||
@@ -222,6 +224,9 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
|
||||
if (message.password !== undefined && message.password !== "") {
|
||||
writer.uint32(26).string(message.password);
|
||||
}
|
||||
if (message.refUsername !== undefined) {
|
||||
writer.uint32(34).string(message.refUsername);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -256,6 +261,14 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
|
||||
message.password = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.refUsername = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -270,6 +283,11 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
|
||||
username: isSet(object.username) ? globalThis.String(object.username) : "",
|
||||
email: isSet(object.email) ? globalThis.String(object.email) : "",
|
||||
password: isSet(object.password) ? globalThis.String(object.password) : "",
|
||||
refUsername: isSet(object.refUsername)
|
||||
? globalThis.String(object.refUsername)
|
||||
: isSet(object.ref_username)
|
||||
? globalThis.String(object.ref_username)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -284,6 +302,9 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
|
||||
if (message.password !== undefined && message.password !== "") {
|
||||
obj.password = message.password;
|
||||
}
|
||||
if (message.refUsername !== undefined) {
|
||||
obj.refUsername = message.refUsername;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -295,6 +316,7 @@ export const RegisterRequest: MessageFns<RegisterRequest> = {
|
||||
message.username = object.username ?? "";
|
||||
message.email = object.email ?? "";
|
||||
message.password = object.password ?? "";
|
||||
message.refUsername = object.refUsername ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
@@ -724,7 +746,7 @@ export const GetGoogleLoginUrlResponse: MessageFns<GetGoogleLoginUrlResponse> =
|
||||
};
|
||||
|
||||
function createBaseCompleteGoogleLoginRequest(): CompleteGoogleLoginRequest {
|
||||
return { code: "" };
|
||||
return { code: "", refUsername: undefined };
|
||||
}
|
||||
|
||||
export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest> = {
|
||||
@@ -732,6 +754,9 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
|
||||
if (message.code !== undefined && message.code !== "") {
|
||||
writer.uint32(10).string(message.code);
|
||||
}
|
||||
if (message.refUsername !== undefined) {
|
||||
writer.uint32(18).string(message.refUsername);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -750,6 +775,14 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
|
||||
message.code = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.refUsername = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -760,7 +793,14 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
|
||||
},
|
||||
|
||||
fromJSON(object: any): CompleteGoogleLoginRequest {
|
||||
return { code: isSet(object.code) ? globalThis.String(object.code) : "" };
|
||||
return {
|
||||
code: isSet(object.code) ? globalThis.String(object.code) : "",
|
||||
refUsername: isSet(object.refUsername)
|
||||
? globalThis.String(object.refUsername)
|
||||
: isSet(object.ref_username)
|
||||
? globalThis.String(object.ref_username)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: CompleteGoogleLoginRequest): unknown {
|
||||
@@ -768,6 +808,9 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
|
||||
if (message.code !== undefined && message.code !== "") {
|
||||
obj.code = message.code;
|
||||
}
|
||||
if (message.refUsername !== undefined) {
|
||||
obj.refUsername = message.refUsername;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -777,6 +820,7 @@ export const CompleteGoogleLoginRequest: MessageFns<CompleteGoogleLoginRequest>
|
||||
fromPartial<I extends Exact<DeepPartial<CompleteGoogleLoginRequest>, I>>(object: I): CompleteGoogleLoginRequest {
|
||||
const message = createBaseCompleteGoogleLoginRequest();
|
||||
message.code = object.code ?? "";
|
||||
message.refUsername = object.refUsername ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { Context, Hono } from "hono";
|
||||
import { deleteCookie } from "hono/cookie";
|
||||
import { deleteCookie, getCookie } from "hono/cookie";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { getAuthServiceClient, getInternalGrpcMetadata } from "../services/grpcClient";
|
||||
import type { User } from "@/server/gen/proto/app/v1/common";
|
||||
@@ -84,6 +84,7 @@ const redirectToGoogleFinalize = (c: Context, status: string, reason?: string) =
|
||||
};
|
||||
|
||||
authRoute.get("/google/callback", async (c) => {
|
||||
const referralCookieName = "ref_username";
|
||||
const oauthError = c.req.query("error")?.trim();
|
||||
if (oauthError) {
|
||||
return redirectToGoogleFinalize(c, "error", oauthError);
|
||||
@@ -96,8 +97,9 @@ authRoute.get("/google/callback", async (c) => {
|
||||
|
||||
try {
|
||||
const grpcCookies: string[] = [];
|
||||
const refUsername = getCookie(c, referralCookieName)?.trim();
|
||||
await authService().completeGoogleLogin(
|
||||
{ code },
|
||||
{ code, refUsername: refUsername || undefined },
|
||||
getInternalGrpcMetadata(),
|
||||
{
|
||||
onMetadata: (metadata) => {
|
||||
@@ -109,9 +111,11 @@ authRoute.get("/google/callback", async (c) => {
|
||||
},
|
||||
},
|
||||
);
|
||||
deleteCookie(c, referralCookieName, { path: "/" });
|
||||
forwardGrpcCookies(c, grpcCookies);
|
||||
return redirectToGoogleFinalize(c, "success");
|
||||
} catch (error) {
|
||||
deleteCookie(c, referralCookieName, { path: "/" });
|
||||
const reason = normalizeGoogleAuthReason(error instanceof Error ? error.message : undefined);
|
||||
return redirectToGoogleFinalize(c, "error", reason);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,31 @@ export const adminMethods = {
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminUser(data, metadata);
|
||||
}),
|
||||
getAdminUser: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.getAdminUser(data, metadata);
|
||||
}),
|
||||
updateAdminUserReferralSettings: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
refUsername: optionalTrimmed(),
|
||||
clearReferrer: z.boolean().optional(),
|
||||
referralEligible: z.boolean().optional(),
|
||||
referralRewardBps: z.number().int().min(0).max(10000).optional(),
|
||||
clearReferralRewardBps: z.boolean().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminUserReferralSettings(data, metadata);
|
||||
}),
|
||||
updateAdminUserRole: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
@@ -282,8 +307,80 @@ export const adminMethods = {
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.deleteAdminAdTemplate(data, metadata);
|
||||
}),
|
||||
listAdminPlayerConfigs: validateFn(
|
||||
z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
userId: optionalTrimmed(),
|
||||
search: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminPlayerConfigs(data, metadata);
|
||||
}),
|
||||
createAdminPlayerConfig: validateFn(
|
||||
z.object({
|
||||
userId: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
autoplay: z.boolean().optional(),
|
||||
loop: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
showControls: z.boolean().optional(),
|
||||
pip: z.boolean().optional(),
|
||||
airplay: z.boolean().optional(),
|
||||
chromecast: z.boolean().optional(),
|
||||
encrytionM3u8: z.boolean().optional(),
|
||||
logoUrl: z.string().trim().optional(),
|
||||
isActive: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminPlayerConfig(data, metadata);
|
||||
}),
|
||||
updateAdminPlayerConfig: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
userId: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
autoplay: z.boolean().optional(),
|
||||
loop: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
showControls: z.boolean().optional(),
|
||||
pip: z.boolean().optional(),
|
||||
airplay: z.boolean().optional(),
|
||||
chromecast: z.boolean().optional(),
|
||||
encrytionM3u8: z.boolean().optional(),
|
||||
logoUrl: z.string().trim().optional(),
|
||||
isActive: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminPlayerConfig(data, metadata);
|
||||
}),
|
||||
deleteAdminPlayerConfig: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.deleteAdminPlayerConfig(data, metadata);
|
||||
}),
|
||||
listAdminJobs: validateFn(
|
||||
z.object({
|
||||
cursor: optionalTrimmed(),
|
||||
pageSize: z.number().int().min(1).max(100).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
agentId: optionalTrimmed(),
|
||||
|
||||
@@ -36,12 +36,18 @@ export const publicAuthMethods = {
|
||||
email: z.string().email("Invalid email"),
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
refUsername: z.string().trim().min(1).optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
const response = await authClient.register(data, metadata);
|
||||
const response = await authClient.register({
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
refUsername: data.refUsername,
|
||||
}, metadata);
|
||||
|
||||
return { user: ensureSessionUser(response.user) };
|
||||
}),
|
||||
|
||||
@@ -139,6 +139,67 @@ export const meMethods = {
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adTemplatesClient.deleteAdTemplate(data, metadata);
|
||||
}),
|
||||
listPlayerConfigs: async () => {
|
||||
const context = getContext();
|
||||
const playerConfigsClient = context.get("playerConfigsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await playerConfigsClient.listPlayerConfigs({}, metadata);
|
||||
},
|
||||
createPlayerConfig: validateFn(
|
||||
z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
autoplay: z.boolean().optional(),
|
||||
loop: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
showControls: z.boolean().optional(),
|
||||
pip: z.boolean().optional(),
|
||||
airplay: z.boolean().optional(),
|
||||
chromecast: z.boolean().optional(),
|
||||
encrytionM3u8: z.boolean().optional(),
|
||||
logoUrl: z.string().trim().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const playerConfigsClient = context.get("playerConfigsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await playerConfigsClient.createPlayerConfig(data, metadata);
|
||||
}),
|
||||
updatePlayerConfig: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
autoplay: z.boolean().optional(),
|
||||
loop: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
showControls: z.boolean().optional(),
|
||||
pip: z.boolean().optional(),
|
||||
airplay: z.boolean().optional(),
|
||||
chromecast: z.boolean().optional(),
|
||||
encrytionM3u8: z.boolean().optional(),
|
||||
logoUrl: z.string().trim().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const playerConfigsClient = context.get("playerConfigsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await playerConfigsClient.updatePlayerConfig(data, metadata);
|
||||
}),
|
||||
deletePlayerConfig: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const playerConfigsClient = context.get("playerConfigsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await playerConfigsClient.deletePlayerConfig(data, metadata);
|
||||
}),
|
||||
getPreferences: async () => {
|
||||
const context = getContext();
|
||||
const preferencesClient = context.get("preferencesServiceClient");
|
||||
@@ -151,13 +212,8 @@ export const meMethods = {
|
||||
pushNotifications: z.boolean().optional(),
|
||||
marketingNotifications: z.boolean().optional(),
|
||||
telegramNotifications: z.boolean().optional(),
|
||||
autoplay: z.boolean().optional(),
|
||||
loop: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
showControls: z.boolean().optional(),
|
||||
pip: z.boolean().optional(),
|
||||
airplay: z.boolean().optional(),
|
||||
chromecast: z.boolean().optional(),
|
||||
language: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
import {
|
||||
AdTemplatesServiceClient,
|
||||
DomainsServiceClient,
|
||||
PlayerConfigsServiceClient,
|
||||
PlansServiceClient,
|
||||
type AdTemplatesServiceClient as AdTemplatesServiceClientType,
|
||||
type DomainsServiceClient as DomainsServiceClientType,
|
||||
type PlayerConfigsServiceClient as PlayerConfigsServiceClientType,
|
||||
type PlansServiceClient as PlansServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/catalog";
|
||||
import {
|
||||
@@ -45,6 +47,7 @@ declare module "hono" {
|
||||
adTemplatesServiceClient: PromisifiedClient<AdTemplatesServiceClientType>;
|
||||
videosServiceClient: PromisifiedClient<VideosServiceClientType>;
|
||||
domainsServiceClient: PromisifiedClient<DomainsServiceClientType>;
|
||||
playerConfigsServiceClient: PromisifiedClient<PlayerConfigsServiceClientType>;
|
||||
plansServiceClient: PromisifiedClient<PlansServiceClientType>;
|
||||
paymentsServiceClient: PromisifiedClient<PaymentsServiceClientType>;
|
||||
preferencesServiceClient: PromisifiedClient<PreferencesServiceClientType>;
|
||||
@@ -164,6 +167,14 @@ export const getDomainsServiceClient = () => {
|
||||
return context.get("domainsServiceClient");
|
||||
};
|
||||
|
||||
export const getPlayerConfigsServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get PlayerConfigsServiceClient");
|
||||
}
|
||||
return context.get("playerConfigsServiceClient");
|
||||
};
|
||||
|
||||
export const getPlansServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
@@ -218,6 +229,7 @@ export const setupServices = (app: Hono) => {
|
||||
const adTemplatesClient = new AdTemplatesServiceClient(grpcAddress(), creds);
|
||||
const videosClient = new VideosServiceClient(grpcAddress(), creds);
|
||||
const domainsClient = new DomainsServiceClient(grpcAddress(), creds);
|
||||
const playerConfigsClient = new PlayerConfigsServiceClient(grpcAddress(), creds);
|
||||
const plansClient = new PlansServiceClient(grpcAddress(), creds);
|
||||
const paymentsClient = new PaymentsServiceClient(grpcAddress(), creds);
|
||||
const preferencesClient = new PreferencesServiceClient(grpcAddress(), creds);
|
||||
@@ -230,6 +242,7 @@ export const setupServices = (app: Hono) => {
|
||||
c.set("adTemplatesServiceClient", promisifyClient(adTemplatesClient));
|
||||
c.set("videosServiceClient", promisifyClient(videosClient));
|
||||
c.set("domainsServiceClient", promisifyClient(domainsClient));
|
||||
c.set("playerConfigsServiceClient", promisifyClient(playerConfigsClient));
|
||||
c.set("plansServiceClient", promisifyClient(plansClient));
|
||||
c.set("paymentsServiceClient", promisifyClient(paymentsClient));
|
||||
c.set("preferencesServiceClient", promisifyClient(preferencesClient));
|
||||
|
||||
@@ -121,8 +121,13 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithGoogle() {
|
||||
async function loginWithGoogle(refUsername?: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (refUsername?.trim()) {
|
||||
document.cookie = `ref_username=${encodeURIComponent(refUsername.trim())}; Path=/; Max-Age=900; SameSite=Lax`;
|
||||
} else {
|
||||
document.cookie = "ref_username=; Path=/; Max-Age=0; SameSite=Lax";
|
||||
}
|
||||
const response = await rpcClient.getGoogleLoginUrl();
|
||||
if (!response.url) {
|
||||
throw new Error(t("auth.errors.unknown"));
|
||||
@@ -130,12 +135,12 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
window.location.assign(response.url);
|
||||
}
|
||||
|
||||
async function register(username: string, email: string, password: string) {
|
||||
async function register(username: string, email: string, password: string, refUsername?: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await rpcClient.register({ username, email, password });
|
||||
await rpcClient.register({ username, email, password, refUsername: refUsername?.trim() || undefined });
|
||||
await router.push("/login");
|
||||
} catch (e: any) {
|
||||
error.value = t("auth.errors.registrationFailed", {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
Reference in New Issue
Block a user