From b60f65e4d1ef8edb2a2f7e4b00e05e0906a2736d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 24 Mar 2026 07:08:44 +0000 Subject: [PATCH] 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. --- bun.lock | 3 + components.d.ts | 4 +- package.json | 1 + public/locales/en/translation.json | 164 +- public/locales/vi/translation.json | 160 +- src/components/DashboardNav.vue | 14 - src/components/OfflineOverlay.vue | 67 + src/components/RootLayout.vue | 4 +- src/components/dashboard/StatsCard.vue | 6 +- src/components/icons/AdvertisementIcon.vue | 4 +- src/components/icons/Chart.vue | 2 +- src/components/icons/Credit.vue | 2 +- src/components/icons/Upload.vue | 4 +- src/components/ui/AppButton.vue | 16 +- src/components/ui/AsyncSelect.vue | 53 +- src/composables/useNetworkStatus.ts | 47 + .../useSettingsPreferencesQuery.ts | 63 +- src/lib/utils.ts | 4 + src/routes/admin/Layout.vue | 138 -- src/routes/admin/Overview.vue | 112 -- .../admin/components/AdminSectionShell.vue | 11 - src/routes/auth/signup.vue | 31 +- src/routes/home/Home.vue | 10 +- src/routes/index.ts | 56 +- src/routes/overview/Overview.vue | 47 +- .../overview/components/AdminOverview.vue | 72 + .../overview/components/NameGradient.vue | 3 +- src/routes/overview/components/Referral.vue | 7 +- .../overview/components/StatsOverview.vue | 20 +- src/routes/settings/AdsVast/AdsVast.vue | 501 +---- .../AdsVast/components/AdsVastDialog.vue | 194 ++ .../AdsVast/components/AdsVastNotices.vue | 26 + .../AdsVast/components/AdsVastTable.tsx | 252 +++ .../AdsVast/components/AdsVastToolbar.vue | 24 + src/routes/settings/AdsVast/types.ts | 6 + src/routes/settings/Billing/Billing.vue | 10 +- .../components}/BillingHistorySection.vue | 0 .../components}/BillingPlansSection.vue | 0 .../components}/BillingTopupDialog.vue | 7 +- .../components}/BillingUsageSection.vue | 0 .../components}/BillingWalletRow.vue | 2 +- src/routes/settings/DomainsDns/DomainsDns.vue | 479 ++--- .../components/DomainsDnsDialog.vue | 69 + .../components/DomainsDnsEmbedCode.vue | 35 + .../components/DomainsDnsNotices.vue | 12 + .../DomainsDns/components/DomainsDnsTable.vue | 90 + .../components/DomainsDnsToolbar.vue | 25 + src/routes/settings/DomainsDns/helpers.ts | 25 + src/routes/settings/DomainsDns/types.ts | 11 + .../NotificationSettings.vue | 6 +- .../settings/PlayerConfigs/PlayerConfigs.vue | 402 ++++ .../components/PlayerConfigDialog.vue | 279 +++ .../components/PlayerConfigSettingsBadges.vue | 40 + .../components/PlayerConfigsNotices.vue | 27 + .../components/PlayerConfigsTable.vue | 156 ++ .../components/PlayerConfigsToolbar.vue | 24 + src/routes/settings/PlayerConfigs/types.ts | 50 + .../PlayerSettings/PlayerSettings.vue | 164 +- .../SecurityNConnected/SecurityNConnected.vue | 309 +-- .../components/SecurityAccountStatusRow.vue | 24 + .../SecurityChangePasswordDialog.vue | 115 ++ .../components/SecurityChangePasswordRow.vue | 30 + .../components/SecurityEmailRow.vue | 30 + .../components/SecurityLanguageRow.vue | 65 + .../components/SecurityLogoutRow.vue | 33 + .../components/SecurityTelegramRow.vue | 48 + .../components/SecurityTwoFactorDialog.vue | 80 + src/routes/settings/Settings.vue | 217 +- .../{ => settings}/admin/AdTemplates.vue | 148 +- src/routes/{ => settings}/admin/Agents.vue | 56 +- src/routes/{ => settings}/admin/Jobs.vue | 157 +- src/routes/settings/admin/Layout.vue | 140 ++ src/routes/{ => settings}/admin/Logs.vue | 54 +- src/routes/{ => settings}/admin/Payments.vue | 113 +- src/routes/{ => settings}/admin/Plans.vue | 187 +- src/routes/settings/admin/PlayerConfigs.vue | 566 ++++++ src/routes/{ => settings}/admin/Users.vue | 278 ++- src/routes/{ => settings}/admin/Videos.vue | 172 +- .../settings/admin/components/AdminInput.vue | 47 + .../admin/components/AdminMetricCard.vue | 46 + .../components/AdminPlaceholderTable.vue | 6 +- .../admin/components/AdminSectionCard.vue | 45 + .../admin/components/AdminSectionShell.vue | 11 + .../settings/admin/components/AdminSelect.vue | 38 + .../settings/admin/components/AdminTable.vue | 73 + .../admin/components/AdminTextarea.vue | 36 + .../admin/components/AdminUserFormFields.vue | 58 + .../admin/components/useAdminPageHeader.ts | 2 +- src/server/gen/proto/app/v1/account.ts | 23 +- src/server/gen/proto/app/v1/admin.ts | 1747 ++++++++++++++++- src/server/gen/proto/app/v1/auth.ts | 50 +- src/server/gen/proto/app/v1/catalog.ts | 1057 +++++++++- src/server/gen/proto/app/v1/common.ts | 1163 ++++++++++- src/server/routes/auth.ts | 8 +- src/server/routes/rpc/admin.ts | 97 + src/server/routes/rpc/auth.ts | 8 +- src/server/routes/rpc/me.ts | 70 +- src/server/services/grpcClient.ts | 13 + src/stores/auth.ts | 11 +- tsconfig.json | 2 + 100 files changed, 9270 insertions(+), 2204 deletions(-) create mode 100644 src/components/OfflineOverlay.vue create mode 100644 src/composables/useNetworkStatus.ts delete mode 100644 src/routes/admin/Layout.vue delete mode 100644 src/routes/admin/Overview.vue delete mode 100644 src/routes/admin/components/AdminSectionShell.vue create mode 100644 src/routes/overview/components/AdminOverview.vue create mode 100644 src/routes/settings/AdsVast/components/AdsVastDialog.vue create mode 100644 src/routes/settings/AdsVast/components/AdsVastNotices.vue create mode 100644 src/routes/settings/AdsVast/components/AdsVastTable.tsx create mode 100644 src/routes/settings/AdsVast/components/AdsVastToolbar.vue create mode 100644 src/routes/settings/AdsVast/types.ts rename src/routes/settings/{components/billing => Billing/components}/BillingHistorySection.vue (100%) rename src/routes/settings/{components/billing => Billing/components}/BillingPlansSection.vue (100%) rename src/routes/settings/{components/billing => Billing/components}/BillingTopupDialog.vue (93%) rename src/routes/settings/{components/billing => Billing/components}/BillingUsageSection.vue (100%) rename src/routes/settings/{components/billing => Billing/components}/BillingWalletRow.vue (95%) create mode 100644 src/routes/settings/DomainsDns/components/DomainsDnsDialog.vue create mode 100644 src/routes/settings/DomainsDns/components/DomainsDnsEmbedCode.vue create mode 100644 src/routes/settings/DomainsDns/components/DomainsDnsNotices.vue create mode 100644 src/routes/settings/DomainsDns/components/DomainsDnsTable.vue create mode 100644 src/routes/settings/DomainsDns/components/DomainsDnsToolbar.vue create mode 100644 src/routes/settings/DomainsDns/helpers.ts create mode 100644 src/routes/settings/DomainsDns/types.ts create mode 100644 src/routes/settings/PlayerConfigs/PlayerConfigs.vue create mode 100644 src/routes/settings/PlayerConfigs/components/PlayerConfigDialog.vue create mode 100644 src/routes/settings/PlayerConfigs/components/PlayerConfigSettingsBadges.vue create mode 100644 src/routes/settings/PlayerConfigs/components/PlayerConfigsNotices.vue create mode 100644 src/routes/settings/PlayerConfigs/components/PlayerConfigsTable.vue create mode 100644 src/routes/settings/PlayerConfigs/components/PlayerConfigsToolbar.vue create mode 100644 src/routes/settings/PlayerConfigs/types.ts create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityAccountStatusRow.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityChangePasswordDialog.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityChangePasswordRow.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityEmailRow.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityLanguageRow.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityLogoutRow.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityTelegramRow.vue create mode 100644 src/routes/settings/SecurityNConnected/components/SecurityTwoFactorDialog.vue rename src/routes/{ => settings}/admin/AdTemplates.vue (77%) rename src/routes/{ => settings}/admin/Agents.vue (90%) rename src/routes/{ => settings}/admin/Jobs.vue (78%) create mode 100644 src/routes/settings/admin/Layout.vue rename src/routes/{ => settings}/admin/Logs.vue (64%) rename src/routes/{ => settings}/admin/Payments.vue (82%) rename src/routes/{ => settings}/admin/Plans.vue (63%) create mode 100644 src/routes/settings/admin/PlayerConfigs.vue rename src/routes/{ => settings}/admin/Users.vue (67%) rename src/routes/{ => settings}/admin/Videos.vue (75%) create mode 100644 src/routes/settings/admin/components/AdminInput.vue create mode 100644 src/routes/settings/admin/components/AdminMetricCard.vue rename src/routes/{ => settings}/admin/components/AdminPlaceholderTable.vue (91%) create mode 100644 src/routes/settings/admin/components/AdminSectionCard.vue create mode 100644 src/routes/settings/admin/components/AdminSectionShell.vue create mode 100644 src/routes/settings/admin/components/AdminSelect.vue create mode 100644 src/routes/settings/admin/components/AdminTable.vue create mode 100644 src/routes/settings/admin/components/AdminTextarea.vue create mode 100644 src/routes/settings/admin/components/AdminUserFormFields.vue rename src/routes/{ => settings}/admin/components/useAdminPageHeader.ts (95%) diff --git a/bun.lock b/bun.lock index abd3a9a..9586b2b 100644 --- a/bun.lock +++ b/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=="], diff --git a/components.d.ts b/components.d.ts index 5378848..d3b46db 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/package.json b/package.json index 3f8e774..67e5ea8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 2a69b33..64b07b6 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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", diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index 93190d0..4e3bd74 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -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", diff --git a/src/components/DashboardNav.vue b/src/components/DashboardNav.vue index 93f3822..42ce198 100644 --- a/src/components/DashboardNav.vue +++ b/src/components/DashboardNav.vue @@ -38,20 +38,6 @@ const links = computed>(() => { }, { 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; }); diff --git a/src/components/OfflineOverlay.vue b/src/components/OfflineOverlay.vue new file mode 100644 index 0000000..e6a6cac --- /dev/null +++ b/src/components/OfflineOverlay.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/components/RootLayout.vue b/src/components/RootLayout.vue index 71eb270..43706ca 100644 --- a/src/components/RootLayout.vue +++ b/src/components/RootLayout.vue @@ -1,10 +1,12 @@ diff --git a/src/components/dashboard/StatsCard.vue b/src/components/dashboard/StatsCard.vue index 6907fb8..cdead9d 100644 --- a/src/components/dashboard/StatsCard.vue +++ b/src/components/dashboard/StatsCard.vue @@ -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(), { +withDefaults(defineProps(), { color: 'primary' }); @@ -49,7 +49,7 @@ const iconColors = {
-

{{ title }}

+

{{ $t(title) }}

{{ value }}

diff --git a/src/components/icons/AdvertisementIcon.vue b/src/components/icons/AdvertisementIcon.vue index 90cc7a7..5203ceb 100644 --- a/src/components/icons/AdvertisementIcon.vue +++ b/src/components/icons/AdvertisementIcon.vue @@ -1,6 +1,6 @@ diff --git a/src/components/ui/AsyncSelect.vue b/src/components/ui/AsyncSelect.vue index ed27523..588a5f9 100644 --- a/src/components/ui/AsyncSelect.vue +++ b/src/components/ui/AsyncSelect.vue @@ -1,7 +1,6 @@ \ No newline at end of file + diff --git a/src/composables/useNetworkStatus.ts b/src/composables/useNetworkStatus.ts new file mode 100644 index 0000000..f9bd1a2 --- /dev/null +++ b/src/composables/useNetworkStatus.ts @@ -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, + } +} diff --git a/src/composables/useSettingsPreferencesQuery.ts b/src/composables/useSettingsPreferencesQuery.ts index 28c39e8..8801370 100644 --- a/src/composables/useSettingsPreferencesQuery.ts +++ b/src/composables/useSettingsPreferencesQuery.ts @@ -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, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0af8d3d..d1f3ec5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -96,4 +96,8 @@ export const getStatusSeverity = (status: string = "") => { default: return 'info'; } +}; +export const isAdmin = (role: string = "") => { + const r = String(role).toLowerCase(); + return r === "admin" || r === "superadmin"; }; \ No newline at end of file diff --git a/src/routes/admin/Layout.vue b/src/routes/admin/Layout.vue deleted file mode 100644 index d4972cd..0000000 --- a/src/routes/admin/Layout.vue +++ /dev/null @@ -1,138 +0,0 @@ - - - diff --git a/src/routes/admin/Overview.vue b/src/routes/admin/Overview.vue deleted file mode 100644 index 4d5f9f3..0000000 --- a/src/routes/admin/Overview.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/src/routes/admin/components/AdminSectionShell.vue b/src/routes/admin/components/AdminSectionShell.vue deleted file mode 100644 index 658e876..0000000 --- a/src/routes/admin/components/AdminSectionShell.vue +++ /dev/null @@ -1,11 +0,0 @@ - diff --git a/src/routes/auth/signup.vue b/src/routes/auth/signup.vue index a792fb5..c8dc51b 100644 --- a/src/routes/auth/signup.vue +++ b/src/routes/auth/signup.vue @@ -37,8 +37,28 @@

{{ errors.password }}

+
+ Signing up with referral: @{{ refUsername }} +
+ {{ t('auth.signup.createAccount') }} +
+
+
+
+
+ {{ t('auth.login.google') }} +
+
+ + + + + + Continue with Google + +

{{ t('auth.signup.alreadyHave') }} 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); }; diff --git a/src/routes/home/Home.vue b/src/routes/home/Home.vue index a648758..f005085 100644 --- a/src/routes/home/Home.vue +++ b/src/routes/home/Home.vue @@ -97,7 +97,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;

{{ signal.label }} @@ -211,7 +211,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;

-
+
tag === scaleTag.value;

-
+
tag === scaleTag.value;

-
+
tag === scaleTag.value;
-
+

diff --git a/src/routes/index.ts b/src/routes/index.ts index 215dde8..ef3953e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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: "admin", + meta: { requiresAdmin: true }, + redirect: { name: "admin-overview" }, + children: [ + { 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") }, + ], + }, ], }, ], diff --git a/src/routes/overview/Overview.vue b/src/routes/overview/Overview.vue index e972791..98143f6 100644 --- a/src/routes/overview/Overview.vue +++ b/src/routes/overview/Overview.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([]); -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(() => [ + { + 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(); }); @@ -44,12 +63,12 @@ onMounted(() => { { label: $t('pageHeader.dashboard') } ]" /> - - - - - - + +

diff --git a/src/routes/overview/components/AdminOverview.vue b/src/routes/overview/components/AdminOverview.vue new file mode 100644 index 0000000..1cce947 --- /dev/null +++ b/src/routes/overview/components/AdminOverview.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/routes/overview/components/NameGradient.vue b/src/routes/overview/components/NameGradient.vue index 79b8fb3..e7b4232 100644 --- a/src/routes/overview/components/NameGradient.vue +++ b/src/routes/overview/components/NameGradient.vue @@ -1,11 +1,10 @@ diff --git a/src/routes/overview/components/Referral.vue b/src/routes/overview/components/Referral.vue index b3ddd70..5763bc7 100644 --- a/src/routes/overview/components/Referral.vue +++ b/src/routes/overview/components/Referral.vue @@ -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) { diff --git a/src/routes/overview/components/StatsOverview.vue b/src/routes/overview/components/StatsOverview.vue index 9c35990..393e6cd 100644 --- a/src/routes/overview/components/StatsOverview.vue +++ b/src/routes/overview/components/StatsOverview.vue @@ -1,17 +1,11 @@ diff --git a/src/routes/settings/AdsVast/components/AdsVastDialog.vue b/src/routes/settings/AdsVast/components/AdsVastDialog.vue new file mode 100644 index 0000000..d05e974 --- /dev/null +++ b/src/routes/settings/AdsVast/components/AdsVastDialog.vue @@ -0,0 +1,194 @@ + + + diff --git a/src/routes/settings/AdsVast/components/AdsVastNotices.vue b/src/routes/settings/AdsVast/components/AdsVastNotices.vue new file mode 100644 index 0000000..7c03307 --- /dev/null +++ b/src/routes/settings/AdsVast/components/AdsVastNotices.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/routes/settings/AdsVast/components/AdsVastTable.tsx b/src/routes/settings/AdsVast/components/AdsVastTable.tsx new file mode 100644 index 0000000..a4bff9a --- /dev/null +++ b/src/routes/settings/AdsVast/components/AdsVastTable.tsx @@ -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, 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, default: null }, + togglingId: { type: String as PropType, default: null }, + defaultingId: { type: String as PropType, 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>(() => ({ + '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 = { + '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[]>(() => [ + { + id: 'template', + header: t('settings.adsVast.table.template'), + accessorFn: (row) => row.name || '', + cell: ({ row }) => ( +
+
+ {row.original.name || ''} + {row.original.isDefault && ( + + {t('settings.adsVast.defaultBadge')} + + )} +
+

+ {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 }) => ( +
+ + {getAdFormatLabel(row.original.adFormat)} + + {row.original.adFormat === 'mid-roll' && row.original.duration && ( + ({row.original.duration}s) + )} +
+ ), + 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 }) => ( +
+ {row.original.vastTagUrl || ''} + copyToClipboard(row.original.vastTagUrl || '')} + v-slots={{ + icon: () => + }} + /> +
+ ), + 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 }) => ( +
+ emit('toggle-active', { template: 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 }) => ( +
+ {row.original.isDefault ? ( + + {t('settings.adsVast.actions.default')} + + ) : ( + emit('set-default', row.original)} + > + {t('settings.adsVast.actions.setDefault')} + + )} + emit('edit', row.original)} + v-slots={{ + icon: () => + }} + /> + emit('delete', row.original)} + v-slots={{ + icon: () => + }} + /> +
+ ), + 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 ? ( + + ) : ( + + 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: () => ( +
+ +

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

+

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

+
+ ) + }} + /> + )} + + ); + }, +}); \ No newline at end of file diff --git a/src/routes/settings/AdsVast/components/AdsVastToolbar.vue b/src/routes/settings/AdsVast/components/AdsVastToolbar.vue new file mode 100644 index 0000000..5b9adaf --- /dev/null +++ b/src/routes/settings/AdsVast/components/AdsVastToolbar.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/routes/settings/AdsVast/types.ts b/src/routes/settings/AdsVast/types.ts new file mode 100644 index 0000000..6cdf819 --- /dev/null +++ b/src/routes/settings/AdsVast/types.ts @@ -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'; diff --git a/src/routes/settings/Billing/Billing.vue b/src/routes/settings/Billing/Billing.vue index 2b6c50a..f674859 100644 --- a/src/routes/settings/Billing/Billing.vue +++ b/src/routes/settings/Billing/Billing.vue @@ -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'; diff --git a/src/routes/settings/components/billing/BillingHistorySection.vue b/src/routes/settings/Billing/components/BillingHistorySection.vue similarity index 100% rename from src/routes/settings/components/billing/BillingHistorySection.vue rename to src/routes/settings/Billing/components/BillingHistorySection.vue diff --git a/src/routes/settings/components/billing/BillingPlansSection.vue b/src/routes/settings/Billing/components/BillingPlansSection.vue similarity index 100% rename from src/routes/settings/components/billing/BillingPlansSection.vue rename to src/routes/settings/Billing/components/BillingPlansSection.vue diff --git a/src/routes/settings/components/billing/BillingTopupDialog.vue b/src/routes/settings/Billing/components/BillingTopupDialog.vue similarity index 93% rename from src/routes/settings/components/billing/BillingTopupDialog.vue rename to src/routes/settings/Billing/components/BillingTopupDialog.vue index 494aa06..36dd479 100644 --- a/src/routes/settings/components/billing/BillingTopupDialog.vue +++ b/src/routes/settings/Billing/components/BillingTopupDialog.vue @@ -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 }}
diff --git a/src/routes/settings/components/billing/BillingUsageSection.vue b/src/routes/settings/Billing/components/BillingUsageSection.vue similarity index 100% rename from src/routes/settings/components/billing/BillingUsageSection.vue rename to src/routes/settings/Billing/components/BillingUsageSection.vue diff --git a/src/routes/settings/components/billing/BillingWalletRow.vue b/src/routes/settings/Billing/components/BillingWalletRow.vue similarity index 95% rename from src/routes/settings/components/billing/BillingWalletRow.vue rename to src/routes/settings/Billing/components/BillingWalletRow.vue index d493378..99fb883 100644 --- a/src/routes/settings/components/billing/BillingWalletRow.vue +++ b/src/routes/settings/Billing/components/BillingWalletRow.vue @@ -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; diff --git a/src/routes/settings/DomainsDns/DomainsDns.vue b/src/routes/settings/DomainsDns/DomainsDns.vue index befb144..b81139a 100644 --- a/src/routes/settings/DomainsDns/DomainsDns.vue +++ b/src/routes/settings/DomainsDns/DomainsDns.vue @@ -1,369 +1,218 @@ diff --git a/src/routes/settings/DomainsDns/components/DomainsDnsDialog.vue b/src/routes/settings/DomainsDns/components/DomainsDnsDialog.vue new file mode 100644 index 0000000..19dbe7a --- /dev/null +++ b/src/routes/settings/DomainsDns/components/DomainsDnsDialog.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/routes/settings/DomainsDns/components/DomainsDnsEmbedCode.vue b/src/routes/settings/DomainsDns/components/DomainsDnsEmbedCode.vue new file mode 100644 index 0000000..39dddf1 --- /dev/null +++ b/src/routes/settings/DomainsDns/components/DomainsDnsEmbedCode.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/routes/settings/DomainsDns/components/DomainsDnsNotices.vue b/src/routes/settings/DomainsDns/components/DomainsDnsNotices.vue new file mode 100644 index 0000000..835fc3c --- /dev/null +++ b/src/routes/settings/DomainsDns/components/DomainsDnsNotices.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue b/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue new file mode 100644 index 0000000..b3b18cd --- /dev/null +++ b/src/routes/settings/DomainsDns/components/DomainsDnsTable.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/routes/settings/DomainsDns/components/DomainsDnsToolbar.vue b/src/routes/settings/DomainsDns/components/DomainsDnsToolbar.vue new file mode 100644 index 0000000..7e73841 --- /dev/null +++ b/src/routes/settings/DomainsDns/components/DomainsDnsToolbar.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/routes/settings/DomainsDns/helpers.ts b/src/routes/settings/DomainsDns/helpers.ts new file mode 100644 index 0000000..eed5b4a --- /dev/null +++ b/src/routes/settings/DomainsDns/helpers.ts @@ -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), +}); diff --git a/src/routes/settings/DomainsDns/types.ts b/src/routes/settings/DomainsDns/types.ts new file mode 100644 index 0000000..f14411b --- /dev/null +++ b/src/routes/settings/DomainsDns/types.ts @@ -0,0 +1,11 @@ +export type DomainApiItem = { + id?: string; + name?: string; + created_at?: string; +}; + +export type DomainItem = { + id: string; + name: string; + addedAt: string; +}; diff --git a/src/routes/settings/NotificationSettings/NotificationSettings.vue b/src/routes/settings/NotificationSettings/NotificationSettings.vue index 0cb46a8..241932d 100644 --- a/src/routes/settings/NotificationSettings/NotificationSettings.vue +++ b/src/routes/settings/NotificationSettings/NotificationSettings.vue @@ -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', diff --git a/src/routes/settings/PlayerConfigs/PlayerConfigs.vue b/src/routes/settings/PlayerConfigs/PlayerConfigs.vue new file mode 100644 index 0000000..bb5fa67 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/PlayerConfigs.vue @@ -0,0 +1,402 @@ + + + diff --git a/src/routes/settings/PlayerConfigs/components/PlayerConfigDialog.vue b/src/routes/settings/PlayerConfigs/components/PlayerConfigDialog.vue new file mode 100644 index 0000000..f3516d4 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/components/PlayerConfigDialog.vue @@ -0,0 +1,279 @@ + + + diff --git a/src/routes/settings/PlayerConfigs/components/PlayerConfigSettingsBadges.vue b/src/routes/settings/PlayerConfigs/components/PlayerConfigSettingsBadges.vue new file mode 100644 index 0000000..84c6457 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/components/PlayerConfigSettingsBadges.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/routes/settings/PlayerConfigs/components/PlayerConfigsNotices.vue b/src/routes/settings/PlayerConfigs/components/PlayerConfigsNotices.vue new file mode 100644 index 0000000..0b59a31 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/components/PlayerConfigsNotices.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/routes/settings/PlayerConfigs/components/PlayerConfigsTable.vue b/src/routes/settings/PlayerConfigs/components/PlayerConfigsTable.vue new file mode 100644 index 0000000..7a60512 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/components/PlayerConfigsTable.vue @@ -0,0 +1,156 @@ + + + diff --git a/src/routes/settings/PlayerConfigs/components/PlayerConfigsToolbar.vue b/src/routes/settings/PlayerConfigs/components/PlayerConfigsToolbar.vue new file mode 100644 index 0000000..cad68c8 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/components/PlayerConfigsToolbar.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/routes/settings/PlayerConfigs/types.ts b/src/routes/settings/PlayerConfigs/types.ts new file mode 100644 index 0000000..b3f1290 --- /dev/null +++ b/src/routes/settings/PlayerConfigs/types.ts @@ -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; +} diff --git a/src/routes/settings/PlayerSettings/PlayerSettings.vue b/src/routes/settings/PlayerSettings/PlayerSettings.vue index d7d37cb..151e173 100644 --- a/src/routes/settings/PlayerSettings/PlayerSettings.vue +++ b/src/routes/settings/PlayerSettings/PlayerSettings.vue @@ -1,166 +1,14 @@ diff --git a/src/routes/settings/SecurityNConnected/SecurityNConnected.vue b/src/routes/settings/SecurityNConnected/SecurityNConnected.vue index 967c1c8..5344b2d 100644 --- a/src/routes/settings/SecurityNConnected/SecurityNConnected.vue +++ b/src/routes/settings/SecurityNConnected/SecurityNConnected.vue @@ -1,19 +1,19 @@ + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityChangePasswordDialog.vue b/src/routes/settings/SecurityNConnected/components/SecurityChangePasswordDialog.vue new file mode 100644 index 0000000..f1dc0e2 --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityChangePasswordDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityChangePasswordRow.vue b/src/routes/settings/SecurityNConnected/components/SecurityChangePasswordRow.vue new file mode 100644 index 0000000..fc3d55f --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityChangePasswordRow.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityEmailRow.vue b/src/routes/settings/SecurityNConnected/components/SecurityEmailRow.vue new file mode 100644 index 0000000..0ffc2de --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityEmailRow.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityLanguageRow.vue b/src/routes/settings/SecurityNConnected/components/SecurityLanguageRow.vue new file mode 100644 index 0000000..1996f32 --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityLanguageRow.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityLogoutRow.vue b/src/routes/settings/SecurityNConnected/components/SecurityLogoutRow.vue new file mode 100644 index 0000000..67b4f3c --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityLogoutRow.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityTelegramRow.vue b/src/routes/settings/SecurityNConnected/components/SecurityTelegramRow.vue new file mode 100644 index 0000000..c2dbb5e --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityTelegramRow.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/routes/settings/SecurityNConnected/components/SecurityTwoFactorDialog.vue b/src/routes/settings/SecurityNConnected/components/SecurityTwoFactorDialog.vue new file mode 100644 index 0000000..9466645 --- /dev/null +++ b/src/routes/settings/SecurityNConnected/components/SecurityTwoFactorDialog.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/routes/settings/Settings.vue b/src/routes/settings/Settings.vue index 27bf111..0c188d7 100644 --- a/src/routes/settings/Settings.vue +++ b/src/routes/settings/Settings.vue @@ -1,63 +1,61 @@ @@ -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 = { - 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(``, 1) }, - { value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon }, + { + to: '/settings/security', + value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(``, 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(() => { 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.', + }, })); diff --git a/src/routes/admin/AdTemplates.vue b/src/routes/settings/admin/AdTemplates.vue similarity index 77% rename from src/routes/admin/AdTemplates.vue rename to src/routes/settings/admin/AdTemplates.vue index efdfd43..ea4516e 100644 --- a/src/routes/admin/AdTemplates.vue +++ b/src/routes/settings/admin/AdTemplates.vue @@ -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[]>(() => [ }, ]); -// 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); @@ -336,40 +340,38 @@ onMounted(loadTemplates);
- +
- - + +
- - + +
Reset Apply
-
+
{{ error }}
- - + - Try a broader template name or clear the owner filter.

- +
-
Page {{ page }} of {{ totalPages }} · {{ total }} records
+
Page {{ page }} of {{ totalPages }} · {{ total }} records
Previous Next
- +
@@ -406,12 +408,12 @@ onMounted(loadTemplates);
-
{{ item.label }}
+
{{ item.label }}
{{ item.value }}
-
VAST URL
+
VAST URL
{{ selectedRow.vastTagUrl }}
@@ -429,36 +431,36 @@ onMounted(loadTemplates);
{{ actionError }}
- - + +
- - + +
- -