refactor: update video components to use AppButton and improve UI consistency

- Refactored CardPopover.vue to enhance menu positioning and accessibility.
- Replaced Button components with AppButton in VideoEditForm.vue and VideoInfoHeader.vue for consistent styling.
- Simplified VideoSkeleton.vue by removing unused Skeleton imports and improving loading states.
- Updated VideoFilters.vue to replace PrimeVue components with native HTML elements for better performance.
- Enhanced VideoGrid.vue and VideoTable.vue with improved selection handling and UI updates.
- Removed unused PrimeVue styles and imports in SSR routes and configuration files.
This commit is contained in:
2026-03-05 01:35:25 +07:00
parent 77ece5224d
commit e1ba24d1bf
32 changed files with 754 additions and 1483 deletions

View File

@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(bun run build)", "Bash(bun run build)",
"mcp__ide__getDiagnostics" "mcp__ide__getDiagnostics",
"Bash(bun install:*)"
] ]
} }
} }

View File

@@ -6,8 +6,6 @@
"name": "holistream", "name": "holistream",
"dependencies": { "dependencies": {
"@pinia/colada": "^0.21.2", "@pinia/colada": "^0.21.2",
"@primeuix/themes": "^2.0.3",
"@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.2", "@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.0",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
@@ -15,7 +13,6 @@
"hono": "^4.11.7", "hono": "^4.11.7",
"is-mobile": "^5.0.0", "is-mobile": "^5.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primevue": "^4.5.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-router": "^5.0.2", "vue-router": "^5.0.2",
@@ -23,7 +20,6 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.23.0", "@cloudflare/vite-plugin": "^1.23.0",
"@primevue/auto-import-resolver": "^4.5.4",
"@types/node": "^25.2.0", "@types/node": "^25.2.0",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4", "@vitejs/plugin-vue-jsx": "^5.1.4",
@@ -237,26 +233,6 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@primeuix/forms": ["@primeuix/forms@0.1.0", "", { "dependencies": { "@primeuix/utils": "^0.6.0" } }, "sha512-LctcQidb+B5PuvAFWH24YH/SIzmHlOabLHpaTeGY/k51iBv1WyCp+5w9JMYuMB/BplSvV0ZGySxQVkN5Azr/aQ=="],
"@primeuix/styled": ["@primeuix/styled@0.7.4", "", { "dependencies": { "@primeuix/utils": "^0.6.1" } }, "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ=="],
"@primeuix/styles": ["@primeuix/styles@2.0.3", "", { "dependencies": { "@primeuix/styled": "^0.7.4" } }, "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw=="],
"@primeuix/themes": ["@primeuix/themes@2.0.3", "", { "dependencies": { "@primeuix/styled": "^0.7.4" } }, "sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg=="],
"@primeuix/utils": ["@primeuix/utils@0.6.4", "", {}, "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg=="],
"@primevue/auto-import-resolver": ["@primevue/auto-import-resolver@4.5.4", "", { "dependencies": { "@primevue/metadata": "4.5.4" } }, "sha512-YQHrZ9PQSG/4K2BwthA2Xuna4WyS0JMHajiHD9PljaDyQtBVwCadX5ZpKcrAUWR8E/1gjva8x/si0RYxxYrRJw=="],
"@primevue/core": ["@primevue/core@4.5.4", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/utils": "^0.6.2" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-lYJJB3wTrDJ8MkLctzHfrPZAqXVxoatjIsswSJzupatf6ZogJHVYADUKcn1JAkLLk8dtV1FA2AxDek663fHO5Q=="],
"@primevue/forms": ["@primevue/forms@4.5.4", "", { "dependencies": { "@primeuix/forms": "^0.1.0", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.4" } }, "sha512-2TlD8oJEtb8vuKzY3jY0W+7NVBC/Qj0m57iWzpMUmGnEKg9sbQ2/ZiU1sTof710/liYgm4FneRTOYHIpVkiJNA=="],
"@primevue/icons": ["@primevue/icons@4.5.4", "", { "dependencies": { "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.4" } }, "sha512-DxgryEc7ZmUqcEhYMcxGBRyFzdtLIoy3jLtlH1zsVSRZaG+iSAcjQ88nvfkZxGUZtZBFL7sRjF6KLq3bJZJwUw=="],
"@primevue/metadata": ["@primevue/metadata@4.5.4", "", {}, "sha512-jJFD0KYm8bPYgFo0JP3Dc2RkyXzrMI1XHQGsEKTysx9Jx2d1XdxtFji/ZsQeoo/RmwUNof5ciZ72URq37rnK+g=="],
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
@@ -551,8 +527,6 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"primevue": ["primevue@4.5.4", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/styles": "^2.0.2", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.4", "@primevue/icons": "4.5.4" } }, "sha512-nTyEohZABFJhVIpeUxgP0EJ8vKcJAhD+Z7DYj95e7ie/MNUCjRNcGjqmE1cXtXi4z54qDfTSI9h2uJ51qz2DIw=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],

18
components.d.ts vendored
View File

@@ -28,9 +28,7 @@ declare module 'vue' {
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default']
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
Button: typeof import('primevue/button')['default']
Chart: typeof import('./src/components/icons/Chart.vue')['default'] Chart: typeof import('./src/components/icons/Chart.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default']
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default'] CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default'] CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
@@ -40,7 +38,6 @@ declare module 'vue' {
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default'] CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default'] DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
Dialog: typeof import('primevue/dialog')['default']
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default'] DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default'] EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default'] EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
@@ -53,19 +50,15 @@ declare module 'vue' {
Home: typeof import('./src/components/icons/Home.vue')['default'] Home: typeof import('./src/components/icons/Home.vue')['default']
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default'] ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default'] InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/components/icons/Layout.vue')['default'] Layout: typeof import('./src/components/icons/Layout.vue')['default']
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default'] LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default'] LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default'] LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default'] MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
Message: typeof import('primevue/message')['default']
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default'] MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default'] NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default'] PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
Paginator: typeof import('primevue/paginator')['default']
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default'] PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
Password: typeof import('primevue/password')['default']
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default'] PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default'] PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default'] PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
@@ -76,10 +69,8 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default'] SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default'] SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
Skeleton: typeof import('primevue/skeleton')['default']
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default'] SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default'] StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
Tag: typeof import('primevue/tag')['default']
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default'] TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default'] TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default'] TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
@@ -116,9 +107,7 @@ declare global {
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default']
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
const Button: typeof import('primevue/button')['default']
const Chart: typeof import('./src/components/icons/Chart.vue')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default']
const Checkbox: typeof import('primevue/checkbox')['default']
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default'] const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default'] const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
@@ -128,7 +117,6 @@ declare global {
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default'] const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default'] const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
const Dialog: typeof import('primevue/dialog')['default']
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default'] const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default'] const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default'] const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
@@ -141,19 +129,15 @@ declare global {
const Home: typeof import('./src/components/icons/Home.vue')['default'] const Home: typeof import('./src/components/icons/Home.vue')['default']
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default'] const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default'] const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
const InputText: typeof import('primevue/inputtext')['default']
const Layout: typeof import('./src/components/icons/Layout.vue')['default'] const Layout: typeof import('./src/components/icons/Layout.vue')['default']
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default'] const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default'] const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default'] const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default'] const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
const Message: typeof import('primevue/message')['default']
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default'] const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default'] const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default'] const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
const Paginator: typeof import('primevue/paginator')['default']
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default'] const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
const Password: typeof import('primevue/password')['default']
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default'] const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
const PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default'] const PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
const PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default'] const PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
@@ -164,10 +148,8 @@ declare global {
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default'] const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default'] const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
const Skeleton: typeof import('primevue/skeleton')['default']
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default'] const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default'] const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
const Tag: typeof import('primevue/tag')['default']
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default'] const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default'] const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default'] const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']

View File

@@ -2,17 +2,15 @@
"name": "holistream", "name": "holistream",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "bun vite",
"build": "vite build", "build": "bun vite build",
"preview": "vite preview", "preview": "bun vite preview",
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareBindings", "cf-typegen": "wrangler types --env-interface CloudflareBindings",
"tail": "wrangler tail" "tail": "wrangler tail"
}, },
"dependencies": { "dependencies": {
"@pinia/colada": "^0.21.2", "@pinia/colada": "^0.21.2",
"@primeuix/themes": "^2.0.3",
"@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.2", "@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.0",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
@@ -20,7 +18,6 @@
"hono": "^4.11.7", "hono": "^4.11.7",
"is-mobile": "^5.0.0", "is-mobile": "^5.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primevue": "^4.5.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-router": "^5.0.2", "vue-router": "^5.0.2",
@@ -28,7 +25,6 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.23.0", "@cloudflare/vite-plugin": "^1.23.0",
"@primevue/auto-import-resolver": "^4.5.4",
"@types/node": "^25.2.0", "@types/node": "^25.2.0",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4", "@vitejs/plugin-vue-jsx": "^5.1.4",

View File

@@ -27,6 +27,7 @@ const links = [
]; ];
//v-tooltip="i.label"
</script> </script>
<template> <template>
@@ -35,7 +36,8 @@ const links = [
<template v-for="i in links" :key="i.label"> <template v-for="i in links" :key="i.label">
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'" <component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)" v-bind="i.type === 'a' ? { to: i.href } : {}"
@click="i.action && i.action($event)"
:class="cn( :class="cn(
i.className, i.className,
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15' ($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
@@ -45,7 +47,5 @@ const links = [
</component> </component>
</template> </template>
</header> </header>
<ClientOnly> <NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
</ClientOnly>
</template> </template>

View File

@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import NotificationItem from '@/routes/notification/components/NotificationItem.vue'; import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import { computed, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
// Emit event when visibility changes // Emit event when visibility changes
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
@@ -121,7 +127,7 @@ defineExpose({ toggle });
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport v-if="isMounted" to="body">
<Transition enter-active-class="transition-all duration-300 ease-out" <Transition enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0" enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0" leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"

View File

@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import XIcon from '@/components/icons/XIcon.vue'; import XIcon from '@/components/icons/XIcon.vue';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { onBeforeUnmount, watch } from 'vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
visible: boolean; visible: boolean;
@@ -47,7 +53,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport v-if="isMounted" to="body">
<Transition <Transition
enter-active-class="transition-all duration-200 ease-out" enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0" enter-from-class="opacity-0"
@@ -67,7 +73,12 @@ onBeforeUnmount(() => {
<!-- Panel --> <!-- Panel -->
<div class="absolute inset-0 flex items-center justify-center p-4"> <div class="absolute inset-0 flex items-center justify-center p-4">
<div :class="cn('w-full bg-surface border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)"> <div :class="cn('w-full bg-surface border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-border"> <!-- Header slot -->
<div v-if="$slots.header" class="px-5 py-4 border-b border-border">
<slot name="header" :close="close" />
</div>
<!-- Default title -->
<div v-else-if="title" class="flex items-center justify-between gap-3 px-5 py-4 border-b border-border">
<h3 class="text-sm font-semibold text-foreground"> <h3 class="text-sm font-semibold text-foreground">
{{ title }} {{ title }}
</h3> </h3>
@@ -82,10 +93,12 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
<!-- Content -->
<div class="p-5"> <div class="p-5">
<slot /> <slot />
</div> </div>
<!-- Footer slot -->
<div v-if="$slots.footer" class="px-5 py-4 border-t border-border bg-muted/20"> <div v-if="$slots.footer" class="px-5 py-4 border-t border-border bg-muted/20">
<slot name="footer" /> <slot name="footer" />
</div> </div>

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +1,12 @@
import { PiniaColada, useQueryCache } from '@pinia/colada'; import { PiniaColada, useQueryCache } from '@pinia/colada';
import { definePreset } from '@primeuix/themes';
import Aura from '@primeuix/themes/aura';
import { createHead as CSRHead } from "@unhead/vue/client"; import { createHead as CSRHead } from "@unhead/vue/client";
import { createHead as SSRHead } from "@unhead/vue/server"; import { createHead as SSRHead } from "@unhead/vue/server";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import ToastService from 'primevue/toastservice';
import Tooltip from 'primevue/tooltip';
import { createSSRApp } from 'vue'; import { createSSRApp } from 'vue';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import { withErrorBoundary } from './lib/hoc/withErrorBoundary'; import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import createAppRouter from './routes'; import createAppRouter from './routes';
const CompactAura = definePreset(Aura, {
semantic: {
formField: {
paddingX: '0.625rem',
paddingY: '0.375rem',
sm: {
fontSize: '0.75rem',
paddingX: '0.5rem',
paddingY: '0.25rem',
},
lg: {
fontSize: '1rem',
paddingX: '0.75rem',
paddingY: '0.5rem',
},
},
},
});
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen" const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
export function createApp() { export function createApp() {
const pinia = createPinia(); const pinia = createPinia();
@@ -38,24 +14,11 @@ export function createApp() {
const head = import.meta.env.SSR ? SSRHead() : CSRHead(); const head = import.meta.env.SSR ? SSRHead() : CSRHead();
app.use(head); app.use(head);
app.use(PrimeVue, {
// unstyled: true,
theme: {
preset: CompactAura,
options: {
darkModeSelector: '.my-app-dark',
cssLayer: false,
}
}
});
app.use(ToastService);
app.use(ConfirmationService);
app.directive('nh', { app.directive('nh', {
created(el) { created(el) {
el.__v_skip = true; el.__v_skip = true;
} }
}); });
app.directive("tooltip", Tooltip)
app.use(pinia); app.use(pinia);
app.use(PiniaColada, { app.use(PiniaColada, {
pinia, pinia,
@@ -84,4 +47,4 @@ export function createApp() {
} }
} }
return { app, router, head, pinia, bodyClass, queryCache }; return { app, router, head, pinia, bodyClass, queryCache };
} }

View File

@@ -1,20 +1,17 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Toast /> <form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2"> <div class="text-sm text-gray-600 mb-2">
Enter your email address and we'll send you a link to reset your password. Enter your email address and we'll send you a link to reset your password.
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label> <label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText name="email" type="email" placeholder="you@example.com" fluid /> <AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{ <p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
$form.email.error?.message }}</Message>
</div> </div>
<Button type="submit" label="Send Reset Link" fluid /> <AppButton type="submit" class="w-full">Send Reset Link</AppButton>
<div class="text-center mt-2"> <div class="text-center mt-2">
<router-link to="/login" replace <router-link to="/login" replace
@@ -26,48 +23,46 @@
Back to Sign in Back to Sign in
</router-link> </router-link>
</div> </div>
</Form> </form>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Form, type FormSubmitEvent } from '@primevue/forms'; import { client } from '@/api/client';
import { zodResolver } from '@primevue/forms/resolvers/zod'; import { useAppToast } from '@/composables/useAppToast';
import Toast from 'primevue/toast';
import { reactive } from 'vue'; import { reactive } from 'vue';
import { z } from 'zod'; import { z } from 'zod';
import { client } from '@/api/client'; const toast = useAppToast();
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
const auth = useAuthStore(); const form = reactive({
const toast = useToast();
const initialValues = reactive({
email: '' email: ''
}); });
const resolver = zodResolver( const errors = reactive<{ email?: string }>({});
z.object({
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
})
);
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const schema = z.object({
if (valid) { email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
client.auth.forgotPasswordCreate({ email: values.email }) });
.then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 }); const onFormSubmit = () => {
}) errors.email = undefined;
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 }); const result = schema.safeParse(form);
}); if (!result.success) {
// forgotPassword(values.email).then(() => { for (const issue of result.error.issues) {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 }); const field = issue.path[0] as keyof typeof errors;
// }).catch(() => { if (field in errors) errors[field] = issue.message;
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 }); }
// }); return;
} }
client.auth.forgotPasswordCreate({ email: form.email })
.then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
});
}; };
</script> </script>

View File

@@ -1,27 +1,41 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Toast /> <form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email</label> <label for="email" class="text-sm font-medium text-gray-700">Email</label>
<InputText name="email" type="text" placeholder="Enter your email" fluid <AppInput id="email" v-model="form.email" type="text" placeholder="Enter your email"
:disabled="auth.loading" /> :disabled="auth.loading" />
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{ <p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
$form.email.error?.message }}</Message>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label> <label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" placeholder="Enter your password" :feedback="false" toggleMask <div class="relative">
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" /> <AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{ placeholder="Enter your password" :disabled="auth.loading" />
$form.password.error?.message }}</Message> <button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1">
<svg v-if="!showPassword" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" /> <input id="remember-me" v-model="form.rememberMe" type="checkbox"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
:disabled="auth.loading" />
<label for="remember-me" class="text-sm text-gray-900">Remember me</label> <label for="remember-me" class="text-sm text-gray-900">Remember me</label>
</div> </div>
<div class="text-sm"> <div class="text-sm">
@@ -31,8 +45,9 @@
</div> </div>
</div> </div>
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid <AppButton type="submit" :loading="auth.loading" class="w-full">
:loading="auth.loading" /> {{ auth.loading ? 'Signing in...' : 'Sign in' }}
</AppButton>
<div class="relative"> <div class="relative">
<div class="absolute inset-0 flex items-center"> <div class="absolute inset-0 flex items-center">
@@ -43,60 +58,71 @@
</div> </div>
</div> </div>
<Button type="button" variant="outlined" severity="secondary" <AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2"
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading"> @click="loginWithGoogle" :disabled="auth.loading">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"> <svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path <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" /> 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> </svg>
Google Google
</Button> </AppButton>
<div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600"> <div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600">
<p class="text-center text-sm text-gray-600"> <p class="text-center text-sm text-gray-600">
Don't have an account? Don't have an account?
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link> <router-link to="/sign-up"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link>
</p> </p>
<!-- <router-link to="/forgot" class="text-blue-600 hover:text-blue-500 hover:underline">Forgot password?</router-link> -->
</div> </div>
</Form> </form>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { Form, type FormSubmitEvent } from '@primevue/forms'; import { useAppToast } from '@/composables/useAppToast';
import { zodResolver } from '@primevue/forms/resolvers/zod'; import { reactive, ref } from 'vue';
import Toast from 'primevue/toast';
import { useToast } from "primevue/usetoast";
import { reactive } from 'vue';
import { z } from 'zod'; import { z } from 'zod';
const t = useToast();
const auth = useAuthStore();
// const $form = Form.useFormContext();
watch(() => auth.error, (newError) => {
if (newError) {
t.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
}
});
const initialValues = reactive({ const toast = useAppToast();
const auth = useAuthStore();
const showPassword = ref(false);
const form = reactive({
email: '', email: '',
password: '', password: '',
rememberMe: false rememberMe: false
}); });
const resolver = zodResolver( const errors = reactive<{ email?: string; password?: string }>({});
z.object({
email: z.string().min(1, { message: 'Email or username is required.' }),
password: z.string().min(1, { message: 'Password is required.' })
})
);
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => { const schema = z.object({
if (valid) auth.login(values.email, values.password); email: z.string().min(1, { message: 'Email or username is required.' }),
password: z.string().min(1, { message: 'Password is required.' })
});
watch(() => auth.error, (newError) => {
if (newError) {
toast.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
}
});
const onFormSubmit = () => {
errors.email = undefined;
errors.password = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
}
auth.login(form.email, form.password);
}; };
const loginWithGoogle = () => { const loginWithGoogle = () => {
auth.loginWithGoogle(); auth.loginWithGoogle();
}; };
</script> </script>

View File

@@ -1,70 +1,89 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit" <form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label> <label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
<InputText name="name" placeholder="John Doe" fluid /> <AppInput id="name" v-model="form.name" placeholder="John Doe" />
<Message v-if="$form.name?.invalid" severity="error" variant="simple">{{ <p v-if="errors.name" class="text-xs text-red-500 mt-0.5">{{ errors.name }}</p>
$form.name.error?.message }}</Message>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label> <label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText name="email" type="email" placeholder="you@example.com" fluid /> <AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{ <p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
$form.email.error?.message }}</Message>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label> <label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" placeholder="Create a password" :feedback="true" toggleMask fluid <div class="relative">
:inputStyle="{ width: '100%' }" /> <AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
placeholder="Create a password" />
<button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1">
<svg v-if="!showPassword" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<small class="text-gray-500">Must be at least 8 characters.</small> <small class="text-gray-500">Must be at least 8 characters.</small>
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{ <p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
$form.password.error?.message }}</Message>
</div> </div>
<Button type="submit" label="Create Account" fluid /> <AppButton type="submit" class="w-full">Create Account</AppButton>
<p class="mt-4 text-center text-sm text-gray-600"> <p class="mt-4 text-center text-sm text-gray-600">
Already have an account? Already have an account?
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign <router-link to="/login"
in</router-link> class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign in</router-link>
</p> </p>
</Form> </form>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { Form, type FormSubmitEvent } from '@primevue/forms'; import { reactive, ref } from 'vue';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import { reactive } from 'vue';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore(); const auth = useAuthStore();
const showPassword = ref(false);
const initialValues = reactive({ const form = reactive({
name: '', name: '',
email: '', email: '',
password: '' password: ''
}); });
const resolver = zodResolver( const errors = reactive<{ name?: string; email?: string; password?: string }>({});
z.object({
name: z.string().min(1, { message: 'Name is required.' }),
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
})
);
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const schema = z.object({
if (valid) { name: z.string().min(1, { message: 'Name is required.' }),
auth.register(values.name, values.email, values.password); email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
});
const onFormSubmit = () => {
errors.name = undefined;
errors.email = undefined;
errors.password = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
} }
auth.register(form.name, form.email, form.password);
}; };
</script> </script>

View File

@@ -4,7 +4,6 @@ import Credit from '@/components/icons/Credit.vue';
import Upload from '@/components/icons/Upload.vue'; import Upload from '@/components/icons/Upload.vue';
import Video from '@/components/icons/Video.vue'; import Video from '@/components/icons/Video.vue';
import { useUIState } from '@/stores/uiState'; import { useUIState } from '@/stores/uiState';
import Skeleton from 'primevue/skeleton';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import Referral from './Referral.vue'; import Referral from './Referral.vue';
interface Props { interface Props {
@@ -46,19 +45,19 @@ const quickActions = [
<template> <template>
<div v-if="loading" class="mb-8"> <div v-if="loading" class="mb-8">
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton> <div class="w-40 h-6 bg-gray-200 rounded animate-pulse mb-4" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200"> <div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton> <div class="w-12 h-12 bg-gray-200 rounded-full animate-pulse mb-4" />
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton> <div class="w-32 h-5 bg-gray-200 rounded animate-pulse mb-2" />
<Skeleton width="100%" height="1rem"></Skeleton> <div class="w-full h-4 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200"> <div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
<Skeleton width="10rem" height="2rem"></Skeleton> <div class="w-40 h-8 bg-gray-200 rounded animate-pulse" />
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton> <div class="w-full h-5 bg-gray-200 rounded animate-pulse my-4" />
<Skeleton width="100%" height="1rem"></Skeleton> <div class="w-full h-4 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,6 @@
import { ModelVideo } from '@/api/client'; import { ModelVideo } from '@/api/client';
import EmptyState from '@/components/dashboard/EmptyState.vue'; import EmptyState from '@/components/dashboard/EmptyState.vue';
import { formatBytes, formatDate, formatDuration } from '@/lib/utils'; import { formatBytes, formatDate, formatDuration } from '@/lib/utils';
import Skeleton from 'primevue/skeleton';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
interface Props { interface Props {
@@ -28,16 +27,16 @@ const getStatusClass = (status?: string) => {
<div class="mb-8"> <div class="mb-8">
<div v-if="loading"> <div v-if="loading">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<Skeleton width="8rem" height="1.5rem"></Skeleton> <div class="w-32 h-6 bg-gray-200 rounded animate-pulse" />
<Skeleton width="5rem" height="1rem"></Skeleton> <div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
</div> </div>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i"> <div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
<div class="flex gap-4"> <div class="flex gap-4">
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton> <div class="w-16 h-10 bg-gray-200 rounded animate-pulse" />
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<Skeleton width="30%" height="1rem"></Skeleton> <div class="w-[30%] h-4 bg-gray-200 rounded animate-pulse" />
<Skeleton width="20%" height="0.8rem"></Skeleton> <div class="w-[20%] h-3 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@
<p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from <p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from
referred users!</p> referred users!</p>
<div class="flex gap-2"> <div class="flex gap-2">
<InputText class="w-full" readonly type="text" :value="url" @click="copyToClipboard" /> <AppInput class="w-full" readonly type="text" :modelValue="url" @click="copyToClipboard" />
<button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied"> <button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied">
<svg v-if="!isCopied" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <svg v-if="!isCopied" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import StatsCard from '@/components/dashboard/StatsCard.vue'; import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils'; import { formatBytes } from '@/lib/utils';
import Skeleton from 'primevue/skeleton';
interface Props { interface Props {
loading: boolean; loading: boolean;
@@ -22,12 +21,11 @@ defineProps<Props>();
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6"> <div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="space-y-2"> <div class="space-y-2">
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton> <div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
<Skeleton width="8rem" height="2rem"></Skeleton> <div class="w-32 h-8 bg-gray-200 rounded animate-pulse" />
</div> </div>
<!-- <Skeleton shape="circle" size="3rem"></Skeleton> -->
</div> </div>
<Skeleton width="4rem" height="1rem"></Skeleton> <div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>

View File

@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUploadQueue } from '@/composables/useUploadQueue'; import { useUploadQueue } from '@/composables/useUploadQueue';
import { useUIState } from '@/stores/uiState'; import { useUIState } from '@/stores/uiState';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue'; import { ref } from 'vue';
import RemoteUrlForm from './components/RemoteUrlForm.vue'; import RemoteUrlForm from './components/RemoteUrlForm.vue';
import UploadDropzone from './components/UploadDropzone.vue'; import UploadDropzone from './components/UploadDropzone.vue';
const uiState = useUIState(); const uiState = useUIState();
const toast = useToast();
const mode = ref<'local' | 'remote'>('local'); const mode = ref<'local' | 'remote'>('local');
const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue(); const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue();
@@ -15,7 +13,7 @@ const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxIt
const handleFilesSelected = (files: FileList) => { const handleFilesSelected = (files: FileList) => {
const result = addFiles(files); const result = addFiles(files);
if (result.duplicates > 0) { if (result.duplicates > 0) {
toast.add({ uiState.toastQueue.push({
severity: 'warn', severity: 'warn',
summary: 'Duplicate files skipped', summary: 'Duplicate files skipped',
detail: `${result.duplicates} file${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`, detail: `${result.duplicates} file${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
@@ -28,7 +26,7 @@ const handleFilesSelected = (files: FileList) => {
const handleRemoteUrls = (urls: string[]) => { const handleRemoteUrls = (urls: string[]) => {
const result = addRemoteUrls(urls); const result = addRemoteUrls(urls);
if (result.duplicates > 0) { if (result.duplicates > 0) {
toast.add({ uiState.toastQueue.push({
severity: 'warn', severity: 'warn',
summary: 'Duplicate URLs skipped', summary: 'Duplicate URLs skipped',
detail: `${result.duplicates} URL${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`, detail: `${result.duplicates} URL${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
@@ -45,102 +43,105 @@ const handleStartUpload = () => {
</script> </script>
<template> <template>
<Dialog v-model:visible="uiState.uploadDialogVisible" modal dismissableMask :style="{ width: '580px', maxWidth: '96vw' }"> <AppDialog
<template #container="{ closeCallback }"> v-model:visible="uiState.uploadDialogVisible"
<div class="flex flex-col bg-white rounded-2xl overflow-hidden shadow-2xl"> :closable="false"
max-width-class="max-w-[580px] w-full"
<!-- Header --> >
<div class="flex items-center justify-between px-6 py-5 border-b border-slate-100"> <template #header="{ close }">
<div class="flex items-center gap-3"> <!-- Header -->
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center shrink-0"> <div class="flex items-center justify-between">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24" <div class="flex items-center gap-3">
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" <div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24"
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
<polyline points="17 8 12 3 7 8" /> stroke-linejoin="round">
<line x1="12" y1="3" x2="12" y2="15" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
</svg> <polyline points="17 8 12 3 7 8" />
</div> <line x1="12" y1="3" x2="12" y2="15" />
<div> </svg>
<h2 class="font-bold text-base text-slate-900 leading-tight">Upload Videos</h2>
<p class="text-sm text-slate-400 leading-tight mt-0.5">Add up to {{ maxItems }} videos per batch</p>
</div>
</div> </div>
<div>
<!-- Mode switcher --> <h2 class="font-bold text-base text-slate-900 leading-tight">Upload Videos</h2>
<div class="flex items-center gap-0.5 bg-slate-100 rounded-xl p-1"> <p class="text-sm text-slate-400 leading-tight mt-0.5">Add up to {{ maxItems }} videos per batch</p>
<button @click="mode = 'local'"
:class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'local' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
Local
</button>
<button @click="mode = 'remote'"
:class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'remote' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
Remote URL
</button>
</div> </div>
</div> </div>
<!-- Input area --> <!-- Mode switcher -->
<div class="p-5" style="height: 320px;"> <div class="flex items-center gap-0.5 bg-slate-100 rounded-xl p-1">
<!-- Queue full warning --> <button @click="mode = 'local'"
<div v-if="remainingSlots === 0" :class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'local' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
class="h-full flex flex-col items-center justify-center gap-4 text-center"> Local
<div class="w-16 h-16 rounded-2xl bg-amber-50 flex items-center justify-center"> </button>
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-amber-500" viewBox="0 0 24 24" <button @click="mode = 'remote'"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" :class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'remote' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
stroke-linejoin="round"> Remote URL
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> </button>
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
</div>
<div>
<p class="text-base font-semibold text-slate-700">Queue is full</p>
<p class="text-sm text-slate-400 mt-1">
Maximum {{ maxItems }} videos per batch.<br>Start or clear the current queue first.
</p>
</div>
</div>
<!-- Dropzone / URL form -->
<Transition v-else enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100" leave-to-class="opacity-0" mode="out-in">
<UploadDropzone v-if="mode === 'local'" :max-files="remainingSlots" @files-selected="handleFilesSelected" />
<RemoteUrlForm v-else :max-urls="remainingSlots" @submit="handleRemoteUrls" />
</Transition>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100">
<span class="text-sm text-slate-400">
<span v-if="remainingSlots < maxItems">
<span class="font-semibold"
:class="remainingSlots === 0 ? 'text-amber-500' : 'text-slate-600'">{{ remainingSlots }}</span>
/ {{ maxItems }} slots remaining
</span>
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
</span>
<div class="flex items-center gap-2">
<button @click="closeCallback"
class="px-5 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-xl transition-all">
Close
</button>
<button v-if="pendingCount > 0" @click="handleStartUpload"
class="flex items-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent/90 text-white text-sm font-semibold rounded-xl transition-all shadow-sm shadow-accent/30">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Start Upload ({{ pendingCount }})
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
</Dialog>
<!-- Input area -->
<div class="h-[320px]">
<!-- Queue full warning -->
<div v-if="remainingSlots === 0"
class="h-full flex flex-col items-center justify-center gap-4 text-center">
<div class="w-16 h-16 rounded-2xl bg-amber-50 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-amber-500" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
</div>
<div>
<p class="text-base font-semibold text-slate-700">Queue is full</p>
<p class="text-sm text-slate-400 mt-1">
Maximum {{ maxItems }} videos per batch.<br>Start or clear the current queue first.
</p>
</div>
</div>
<!-- Dropzone / URL form -->
<Transition v-else enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100" leave-to-class="opacity-0" mode="out-in">
<UploadDropzone v-if="mode === 'local'" :max-files="remainingSlots" @files-selected="handleFilesSelected" />
<RemoteUrlForm v-else :max-urls="remainingSlots" @submit="handleRemoteUrls" />
</Transition>
</div>
<template #footer>
<!-- Footer -->
<div class="flex items-center justify-between">
<span class="text-sm text-slate-400">
<span v-if="remainingSlots < maxItems">
<span class="font-semibold"
:class="remainingSlots === 0 ? 'text-amber-500' : 'text-slate-600'">{{ remainingSlots }}</span>
/ {{ maxItems }} slots remaining
</span>
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
</span>
<div class="flex items-center gap-2">
<button @click="uiState.uploadDialogVisible = false"
class="px-5 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-xl transition-all">
Close
</button>
<button v-if="pendingCount > 0" @click="handleStartUpload"
class="flex items-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent/90 text-white text-sm font-semibold rounded-xl transition-all shadow-sm shadow-accent/30">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Start Upload ({{ pendingCount }})
</button>
</div>
</div>
</template>
</AppDialog>
</template> </template>

View File

@@ -23,29 +23,49 @@ const handleSubmit = () => {
<template> <template>
<div class="flex flex-col gap-3 h-full"> <div class="flex flex-col gap-3 h-full">
<div class="relative flex-1"> <div class="relative flex-1">
<textarea v-model="urls" <textarea
v-model="urls"
placeholder="Paste video URLs here, one per line&#10;&#10;https://example.com/video.mp4&#10;https://drive.google.com/..." placeholder="Paste video URLs here, one per line&#10;&#10;https://example.com/video.mp4&#10;https://drive.google.com/..."
class="w-full h-full min-h-[200px] px-4 py-3.5 bg-white border border-slate-200 class="w-full h-full min-h-[200px] px-4 py-3.5 bg-white border border-slate-200
rounded-xl focus:border-accent focus:ring-2 focus:ring-accent/10 focus:outline-none rounded-xl focus:border-accent focus:ring-2 focus:ring-accent/10 focus:outline-none
transition-all resize-none text-base text-slate-700 placeholder:text-slate-300 transition-all resize-none text-base text-slate-700 placeholder:text-slate-300
leading-relaxed font-[inherit]"></textarea> leading-relaxed font-[inherit]"
/>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm text-slate-400"> <div class="flex items-center gap-2 text-sm text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" <svg
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
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" /> <circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" /> <path d="M12 16v-4" />
<path d="M12 8h.01" /> <path d="M12 8h.01" />
</svg> </svg>
Google Drive, Dropbox supported Google Drive, Dropbox supported
</div> </div>
<button @click="handleSubmit" <button
@click="handleSubmit"
class="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-900 text-white class="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-900 text-white
text-sm font-semibold rounded-xl transition-all active:scale-95"> text-sm font-semibold rounded-xl transition-all active:scale-95"
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" >
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" /> <path d="M5 12h14" />
<path d="m12 5 7 7-7 7" /> <path d="m12 5 7 7-7 7" />
</svg> </svg>

View File

@@ -1,11 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelVideo } from '@/api/client'; import type { ModelVideo } from '@/api/client';
import { fetchMockVideoById } from '@/mocks/videos'; import { fetchMockVideoById } from '@/mocks/videos';
import Button from 'primevue/button'; import { useAppToast } from '@/composables/useAppToast';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Skeleton from 'primevue/skeleton';
import { useToast } from 'primevue/usetoast';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
const props = defineProps<{ const props = defineProps<{
@@ -16,7 +12,7 @@ const emit = defineEmits<{
(e: 'close'): void; (e: 'close'): void;
}>(); }>();
const toast = useToast(); const toast = useAppToast();
const video = ref<ModelVideo | null>(null); const video = ref<ModelVideo | null>(null);
const loading = ref(true); const loading = ref(true);
const copiedField = ref<string | null>(null); const copiedField = ref<string | null>(null);
@@ -90,31 +86,24 @@ watch(() => props.videoId, (newId) => {
</script> </script>
<template> <template>
<Dialog :visible="!!videoId" @update:visible="emit('close')" modal dismissableMask <AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:style="{ width: '600px', maxWidth: '90vw' }"> :title="loading ? '' : 'Get sharing address'">
<!-- Header -->
<template #header>
<div v-if="loading" class="flex items-center gap-3">
<Skeleton width="12rem" height="1.25rem" />
</div>
<span v-else class="font-semibold text-lg">Get sharing address</span>
</template>
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-5"> <div v-if="loading" class="flex flex-col gap-5">
<div> <div>
<Skeleton width="8rem" height="0.75rem" class="mb-3" /> <div class="w-32 h-3 bg-gray-200 rounded animate-pulse mb-3" />
<div v-for="i in 3" :key="i" class="flex flex-col gap-1.5 mb-4"> <div v-for="i in 3" :key="i" class="flex flex-col gap-1.5 mb-4">
<Skeleton width="40%" height="0.75rem" /> <div class="w-2/5 h-3 bg-gray-200 rounded animate-pulse" />
<div class="flex gap-2"> <div class="flex gap-2">
<Skeleton width="100%" height="2.25rem" borderRadius="6px" /> <div class="w-full h-9 bg-gray-200 rounded-md animate-pulse" />
<Skeleton width="2.75rem" height="2.25rem" borderRadius="6px" /> <div class="w-11 h-9 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Skeleton width="100%" height="4rem" borderRadius="6px" /> <div class="w-full h-16 bg-gray-200 rounded-md animate-pulse" />
<Skeleton width="100%" height="4rem" borderRadius="6px" /> <div class="w-full h-16 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
</div> </div>
@@ -127,9 +116,9 @@ watch(() => props.videoId, (newId) => {
<div v-for="link in shareLinks" :key="link.key" class="flex flex-col gap-1.5"> <div v-for="link in shareLinks" :key="link.key" class="flex flex-col gap-1.5">
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p> <p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<InputText :value="link.value || ''" :placeholder="link.placeholder" readonly <AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly
class="flex-1 !font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" /> input-class="!font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" />
<Button severity="secondary" outlined :disabled="!link.value || copiedField === link.key" <AppButton variant="secondary" :disabled="!link.value || copiedField === link.key"
@click="copyToClipboard(link.value, link.key)" class="shrink-0"> @click="copyToClipboard(link.value, link.key)" class="shrink-0">
<!-- Copy icon --> <!-- Copy icon -->
<svg v-if="copiedField !== link.key" xmlns="http://www.w3.org/2000/svg" width="16" <svg v-if="copiedField !== link.key" xmlns="http://www.w3.org/2000/svg" width="16"
@@ -144,7 +133,7 @@ watch(() => props.videoId, (newId) => {
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5" /> <path d="M20 6 9 17l-5-5" />
</svg> </svg>
</Button> </AppButton>
</div> </div>
<p v-if="link.hint" class="text-xs text-muted-foreground">{{ link.hint }}</p> <p v-if="link.hint" class="text-xs text-muted-foreground">{{ link.hint }}</p>
</div> </div>
@@ -154,14 +143,14 @@ watch(() => props.videoId, (newId) => {
<!-- Notices --> <!-- Notices -->
<div class="flex flex-col gap-2 text-sm"> <div class="flex flex-col gap-2 text-sm">
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-3 flex items-start gap-3"> <div class="rounded-xl border border-red-500/30 bg-red-500/10 p-3 flex items-start gap-3">
<div class="flex-1 text-sm"> <div class="flex-1 text-sm">
<p class="font-medium text-red-900 dark:text-red-100 mb-1">Warning</p> <p class="font-medium text-red-900 dark:text-red-100 mb-1">Warning</p>
<p class="text-red-800 dark:text-red-200">Make sure shared files comply with <strong>local laws</strong> and confirm you understand the responsibilities involved when distributing content.</p> <p class="text-red-800 dark:text-red-200">Make sure shared files comply with <strong>local laws</strong> and confirm you understand the responsibilities involved when distributing content.</p>
</div> </div>
</div> </div>
<div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-3"> <div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-3">
<div class="flex-1 text-sm"> <div class="flex-1 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100 mb-1">Reminder</p> <p class="font-medium text-amber-900 dark:text-amber-100 mb-1">Reminder</p>
<p class="text-amber-800 dark:text-amber-200">The embed player can auto switch fallback nodes and works well on mobile. Raw HLS links <p class="text-amber-800 dark:text-amber-200">The embed player can auto switch fallback nodes and works well on mobile. Raw HLS links
@@ -170,5 +159,5 @@ watch(() => props.videoId, (newId) => {
</div> </div>
</div> </div>
</div> </div>
</Dialog> </AppDialog>
</template> </template>

View File

@@ -2,9 +2,8 @@
import type { ModelVideo } from '@/api/client'; import type { ModelVideo } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import { deleteMockVideo, fetchMockVideoById, updateMockVideo } from '@/mocks/videos'; import { deleteMockVideo, fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
import ConfirmDialog from 'primevue/confirmdialog'; import { useAppConfirm } from '@/composables/useAppConfirm';
import { useConfirm } from 'primevue/useconfirm'; import { useAppToast } from '@/composables/useAppToast';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import VideoEditForm from './components/Detail/VideoEditForm.vue'; import VideoEditForm from './components/Detail/VideoEditForm.vue';
@@ -14,8 +13,8 @@ import VideoSkeleton from './components/Detail/VideoSkeleton.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useAppToast();
const confirm = useConfirm(); const confirm = useAppConfirm();
const videoId = route.params.id as string; const videoId = route.params.id as string;
const video = ref<ModelVideo | null>(null); const video = ref<ModelVideo | null>(null);
@@ -83,8 +82,8 @@ const handleDelete = () => {
confirm.require({ confirm.require({
message: 'Are you sure you want to delete this video? This action cannot be undone.', message: 'Are you sure you want to delete this video? This action cannot be undone.',
header: 'Confirm Delete', header: 'Confirm Delete',
icon: 'pi pi-exclamation-triangle', acceptLabel: 'Delete',
acceptClass: 'p-button-danger', rejectLabel: 'Cancel',
accept: async () => { accept: async () => {
try { try {
await deleteMockVideo(videoId); await deleteMockVideo(videoId);
@@ -132,7 +131,6 @@ const videoInfos = computed(() => {
<template> <template>
<div> <div>
<ConfirmDialog />
<PageHeader title="Video Detail" description="View and manage video details" :breadcrumbs="[ <PageHeader title="Video Detail" description="View and manage video details" :breadcrumbs="[
{ label: 'Dashboard', to: '/' }, { label: 'Dashboard', to: '/' },
{ label: 'Videos', to: '/video' }, { label: 'Videos', to: '/video' },

View File

@@ -1,18 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelVideo } from '@/api/client'; import type { ModelVideo } from '@/api/client';
import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos'; import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
import { Form, type FormSubmitEvent } from '@primevue/forms'; import { useAppToast } from '@/composables/useAppToast';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Message from 'primevue/message';
import Skeleton from 'primevue/skeleton';
import Tag from 'primevue/tag';
import Textarea from 'primevue/textarea';
import { useToast } from 'primevue/usetoast';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { z } from 'zod';
const props = defineProps<{ const props = defineProps<{
videoId: string; videoId: string;
@@ -22,22 +12,17 @@ const emit = defineEmits<{
(e: 'close'): void; (e: 'close'): void;
}>(); }>();
const toast = useToast(); const toast = useAppToast();
const video = ref<ModelVideo | null>(null); const video = ref<ModelVideo | null>(null);
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
const initialValues = ref({ const form = ref({
title: '', title: '',
description: '', description: '',
}); });
const resolver = zodResolver( const errors = ref<{ title?: string; description?: string }>({});
z.object({
title: z.string().min(1, { message: 'Title is required.' }),
description: z.string().optional(),
})
);
const subtitleForm = ref({ const subtitleForm = ref({
file: null as File | null, file: null as File | null,
@@ -51,7 +36,7 @@ const fetchVideo = async () => {
const videoData = await fetchMockVideoById(props.videoId); const videoData = await fetchMockVideoById(props.videoId);
if (videoData) { if (videoData) {
video.value = videoData; video.value = videoData;
initialValues.value = { form.value = {
title: videoData.title || '', title: videoData.title || '',
description: videoData.description || '', description: videoData.description || '',
}; };
@@ -64,20 +49,27 @@ const fetchVideo = async () => {
} }
}; };
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => { const validate = (): boolean => {
if (!valid) return; errors.value = {};
if (!form.value.title.trim()) {
errors.value.title = 'Title is required.';
}
return Object.keys(errors.value).length === 0;
};
const onFormSubmit = async () => {
if (!validate()) return;
saving.value = true; saving.value = true;
try { try {
const payload = { title: values.title as string, description: values.description as string }; await updateMockVideo(props.videoId, form.value);
await updateMockVideo(props.videoId, payload);
if (video.value) { if (video.value) {
video.value.title = payload.title; video.value.title = form.value.title;
video.value.description = payload.description; video.value.description = form.value.description;
} }
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 }); toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
close(); emit('close');
} catch (error) { } catch (error) {
console.error('Failed to save video:', error); console.error('Failed to save video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 }); toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
@@ -102,80 +94,77 @@ const handleUploadSubtitle = () => {
watch(() => props.videoId, (newId) => { watch(() => props.videoId, (newId) => {
if (newId) { if (newId) {
errors.value = {};
fetchVideo(); fetchVideo();
} }
}, { immediate: true }); }, { immediate: true });
</script> </script>
<template> <template>
<Dialog :visible="!!videoId" @update:visible="emit('close')" modal dismissableMask <AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:style="{ width: '600px', maxWidth: '90vw' }"> :title="loading ? '' : 'Edit video'">
<!-- Header -->
<template #header>
<div v-if="loading" class="flex items-center gap-3">
<Skeleton width="8rem" height="1.25rem" />
</div>
<span v-else class="font-semibold text-lg">Edit video</span>
</template>
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-4"> <div v-if="loading" class="flex flex-col gap-4">
<!-- Title skeleton --> <!-- Title skeleton -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Skeleton width="3rem" height="0.875rem" /> <div class="w-12 h-3.5 bg-gray-200 rounded animate-pulse" />
<Skeleton width="100%" height="2.5rem" borderRadius="6px" /> <div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
<!-- Description skeleton --> <!-- Description skeleton -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Skeleton width="5rem" height="0.875rem" /> <div class="w-20 h-3.5 bg-gray-200 rounded animate-pulse" />
<Skeleton width="100%" height="6rem" borderRadius="6px" /> <div class="w-full h-24 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
<!-- Subtitles section skeleton --> <!-- Subtitles section skeleton -->
<div class="flex flex-col gap-3 border-t border-surface pt-4"> <div class="flex flex-col gap-3 border-t border-gray-200 pt-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Skeleton width="4rem" height="0.875rem" /> <div class="w-16 h-3.5 bg-gray-200 rounded animate-pulse" />
<Skeleton width="4.5rem" height="1.5rem" borderRadius="16px" /> <div class="w-[4.5rem] h-6 bg-gray-200 rounded-full animate-pulse" />
</div> </div>
<Skeleton width="60%" height="0.875rem" /> <div class="w-3/5 h-3.5 bg-gray-200 rounded animate-pulse" />
<div class="flex flex-col gap-3 rounded-lg border border-surface p-3"> <div class="flex flex-col gap-3 rounded-lg border border-gray-200 p-3">
<Skeleton width="6rem" height="0.875rem" /> <div class="w-24 h-3.5 bg-gray-200 rounded animate-pulse" />
<Skeleton width="100%" height="2.25rem" borderRadius="6px" /> <div class="w-full h-9 bg-gray-200 rounded-md animate-pulse" />
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<Skeleton width="100%" height="2.25rem" borderRadius="6px" /> <div class="w-full h-9 bg-gray-200 rounded-md animate-pulse" />
<Skeleton width="100%" height="2.25rem" borderRadius="6px" /> <div class="w-full h-9 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
<Skeleton width="100%" height="2.5rem" borderRadius="6px" /> <div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
</div> </div>
<!-- Footer skeleton -->
<div class="flex justify-end gap-2 border-t border-gray-200 pt-4">
<div class="w-20 h-10 bg-gray-200 rounded-md animate-pulse" />
<div class="w-32 h-10 bg-gray-200 rounded-md animate-pulse" />
</div>
</div> </div>
<!-- Form Content --> <!-- Form Content -->
<Form v-else v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit" <form v-else @submit.prevent="onFormSubmit" class="flex flex-col gap-4">
class="flex flex-col gap-4">
<!-- Title --> <!-- Title -->
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="edit-title" class="text-sm font-medium">Title</label> <label for="edit-title" class="text-sm font-medium">Title</label>
<InputText id="edit-title" name="title" placeholder="Enter video title" fluid /> <AppInput id="edit-title" v-model="form.title" placeholder="Enter video title" />
<Message v-if="$form.title?.invalid" severity="error" size="small" variant="simple"> <p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p>
{{ $form.title.error?.message }}
</Message>
</div> </div>
<!-- Description --> <!-- Description -->
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="edit-description" class="text-sm font-medium">Description</label> <label for="edit-description" class="text-sm font-medium">Description</label>
<Textarea id="edit-description" name="description" placeholder="Enter video description" <textarea id="edit-description" v-model="form.description" placeholder="Enter video description"
:rows="4" autoResize fluid /> rows="4"
<Message v-if="$form.description?.invalid" severity="error" size="small" variant="simple"> class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y" />
{{ $form.description.error?.message }} <p v-if="errors.description" class="text-xs text-red-500 mt-0.5">{{ errors.description }}</p>
</Message>
</div> </div>
<!-- Subtitles Section --> <!-- Subtitles Section -->
<div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4"> <div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-sm font-medium">Subtitles</label> <label class="text-sm font-medium">Subtitles</label>
<Tag value="0 tracks" severity="secondary" /> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
0 tracks
</span>
</div> </div>
<p class="text-sm text-muted-foreground">No subtitles uploaded yet</p> <p class="text-sm text-muted-foreground">No subtitles uploaded yet</p>
@@ -196,34 +185,27 @@ watch(() => props.videoId, (newId) => {
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="subtitle-language" class="text-xs font-medium">Language Code *</label> <label for="subtitle-language" class="text-xs font-medium">Language Code *</label>
<InputText id="subtitle-language" v-model="subtitleForm.language" placeholder="en, vi, etc." <AppInput id="subtitle-language" v-model="subtitleForm.language" placeholder="en, vi, etc."
:maxlength="10" size="small" /> :maxlength="10" />
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="subtitle-name" class="text-xs font-medium">Display Name (Optional)</label> <label for="subtitle-name" class="text-xs font-medium">Display Name (Optional)</label>
<InputText id="subtitle-name" v-model="subtitleForm.displayName" <AppInput id="subtitle-name" v-model="subtitleForm.displayName"
placeholder="English, Tiếng Việt, etc." size="small" /> placeholder="English, Tiếng Việt, etc." />
</div> </div>
</div> </div>
<Button label="Upload Subtitle" icon="i-carbon-upload" severity="secondary" outlined <AppButton variant="secondary" class="w-full" :disabled="!canUploadSubtitle" @click="handleUploadSubtitle">
class="w-full" :disabled="!canUploadSubtitle" @click="handleUploadSubtitle" /> Upload Subtitle
</AppButton>
</div> </div>
</div> </div>
<!-- Footer inside Form so submit works --> <!-- Footer inside Form so submit works -->
<div class="flex justify-end gap-2 border-t border-surface pt-4"> <div class="flex justify-end gap-2 border-t border-gray-200 pt-4">
<Button label="Cancel" type="button" text severity="secondary" @click="emit('close')" /> <AppButton variant="ghost" type="button" @click="emit('close')">Cancel</AppButton>
<Button label="Save Changes" type="submit" icon="i-carbon-checkmark" :loading="saving" /> <AppButton type="submit" :loading="saving">Save Changes</AppButton>
</div> </div>
</Form> </form>
</AppDialog>
<!-- Footer skeleton when loading --> </template>
<template v-if="loading" #footer>
<div class="flex justify-end gap-2">
<Skeleton width="5rem" height="2.5rem" borderRadius="6px" />
<Skeleton width="8rem" height="2.5rem" borderRadius="6px" />
</div>
</template>
</Dialog>
</template>

View File

@@ -8,7 +8,7 @@ import { useRouter } from 'vue-router';
import { useUploadQueue } from '@/composables/useUploadQueue'; import { useUploadQueue } from '@/composables/useUploadQueue';
import { useUIState } from '@/stores/uiState'; import { useUIState } from '@/stores/uiState';
import { useToast } from 'primevue/usetoast'; import { useAppToast } from '@/composables/useAppToast';
import VideoBulkActions from './components/VideoBulkActions.vue'; import VideoBulkActions from './components/VideoBulkActions.vue';
import VideoFilters from './components/VideoFilters.vue'; import VideoFilters from './components/VideoFilters.vue';
import VideoTable from './components/VideoTable.vue'; import VideoTable from './components/VideoTable.vue';
@@ -20,7 +20,7 @@ const copyVideoId = ref<string>("");
const uiState = useUIState(); const uiState = useUIState();
const { addFiles, startQueue } = useUploadQueue(); const { addFiles, startQueue } = useUploadQueue();
const toast = useToast(); const toast = useAppToast();
const router = useRouter(); const router = useRouter();
const videos = ref<ModelVideo[]>([]); const videos = ref<ModelVideo[]>([]);
const loading = ref(true); const loading = ref(true);

View File

@@ -1,21 +1,37 @@
<template> <template>
<div class="card flex justify-center"> <div class="relative" ref="containerRef">
<Button type="button" class="!border-none" @click="toggle" severity="secondary" variant="text" aria-haspopup="true" aria-controls="overlay_menu"> <button type="button" class="p-1.5 rounded-md hover:bg-gray-100 transition-colors" @click="toggle"
aria-haspopup="true" :aria-expanded="isOpen">
<EllipsisVerticalIcon class="w-4 h-4 text-gray-500" /> <EllipsisVerticalIcon class="w-4 h-4 text-gray-500" />
</Button> </button>
<Menu ref="menu" id="overlay_menu" :model="items as any" :popup="true" class="min-w-[160px]">
<template #item="{ item, props }"> <Teleport to="body">
<router-link v-if="(item as any).route" v-bind="props.action" :to="(item as any).route" class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded cursor-pointer"> <div v-if="isOpen" class="fixed inset-0 z-40" @click="isOpen = false" />
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" /> <Transition enter-active-class="transition duration-100 ease-out"
<span :class="(item as any).labelClass">{{ item.label }}</span> enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
</router-link> leave-active-class="transition duration-75 ease-in"
<a v-else-if="!(item as any).separator" v-bind="props.action" @click="(item as any).command" class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded cursor-pointer"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" /> <div v-if="isOpen" ref="menuRef"
<span :class="(item as any).labelClass">{{ item.label }}</span> class="fixed z-50 min-w-[160px] bg-white rounded-lg border border-gray-200 shadow-lg py-1"
</a> :style="menuStyle">
</template> <template v-for="(item, index) in items" :key="index">
</Menu> <div v-if="item.separator" class="h-px bg-gray-200 my-1" />
<router-link v-else-if="item.route" :to="item.route"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
@click="isOpen = false">
<component :is="item.icon" class="w-4 h-4" :class="item.iconClass" />
<span :class="item.labelClass">{{ item.label }}</span>
</router-link>
<button v-else type="button"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm w-full text-left"
@click="item.command?.(); isOpen = false">
<component :is="item.icon" class="w-4 h-4" :class="item.iconClass" />
<span :class="item.labelClass">{{ item.label }}</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
@@ -27,9 +43,8 @@ import PencilIcon from "@/components/icons/PencilIcon.vue";
import TrashIcon from "@/components/icons/TrashIcon.vue"; import TrashIcon from "@/components/icons/TrashIcon.vue";
import EllipsisVerticalIcon from "@/components/icons/EllipsisVerticalIcon.vue"; import EllipsisVerticalIcon from "@/components/icons/EllipsisVerticalIcon.vue";
import type { ModelVideo } from '@/api/client'; import type { ModelVideo } from '@/api/client';
import { useToast } from "primevue/usetoast"; import { useAppToast } from "@/composables/useAppToast";
import Menu from "primevue/menu"; import { computed, nextTick, ref, shallowRef } from "vue";
import { computed, ref, shallowRef } from "vue";
import type { RouteLocationRaw } from "vue-router"; import type { RouteLocationRaw } from "vue-router";
const props = defineProps<{ const props = defineProps<{
@@ -40,13 +55,34 @@ const emit = defineEmits<{
(e: 'delete'): void; (e: 'delete'): void;
}>(); }>();
const toast = useToast(); const toast = useAppToast();
const menu = ref<InstanceType<typeof Menu>>(); const isOpen = ref(false);
const containerRef = ref<HTMLElement>();
const menuRef = ref<HTMLElement>();
const menuStyle = ref<Record<string, string>>({});
const videoUrl = computed(() => { const videoUrl = computed(() => {
return `${window.location.origin}/videos/${props.video.id}`; return `${window.location.origin}/videos/${props.video.id}`;
}); });
const toggle = async () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
await nextTick();
positionMenu();
}
};
const positionMenu = () => {
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
menuStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.right}px`,
transform: 'translateX(-100%)',
};
};
const handleCopyLink = async () => { const handleCopyLink = async () => {
try { try {
await navigator.clipboard.writeText(videoUrl.value); await navigator.clipboard.writeText(videoUrl.value);
@@ -74,7 +110,7 @@ const handleDownload = () => {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Thành công', summary: 'Thành công',
@@ -132,8 +168,4 @@ const items = shallowRef<CustomMenuItem[]>([
command: handleDelete command: handleDelete
} }
]); ]);
const toggle = (event: Event) => {
menu.value?.toggle(event);
};
</script> </script>

View File

@@ -30,7 +30,7 @@ const emit = defineEmits<{
@input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea> @input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea>
</div> </div>
<div class="float-right flex gap-2"> <div class="float-right flex gap-2">
<Button size="small" <AppButton size="sm"
title="Save changes" :disabled="saving" @click="$emit('save')"> title="Save changes" :disabled="saving" @click="$emit('save')">
<svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
@@ -38,17 +38,17 @@ const emit = defineEmits<{
<span v-if="saving" <span v-if="saving"
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span> class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span class="hidden sm:inline">{{ saving ? 'Saving...' : 'Save' }}</span> <span class="hidden sm:inline">{{ saving ? 'Saving...' : 'Save' }}</span>
</Button> </AppButton>
<!-- Cancel Button (Edit Mode) --> <!-- Cancel Button (Edit Mode) -->
<Button severity="danger" size="small" title="Cancel editing" <AppButton variant="danger" size="sm" title="Cancel editing"
@click="$emit('toggleEdit')"> @click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Cancel</span> <span class="hidden sm:inline">Cancel</span>
</Button> </AppButton>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelVideo } from '@/api/client'; import type { ModelVideo } from '@/api/client';
import { getStatusSeverity } from '@/lib/utils'; import { getStatusSeverity } from '@/lib/utils';
import Tag from 'primevue/tag';
const props = defineProps<{ const props = defineProps<{
video: ModelVideo; video: ModelVideo;
@@ -42,6 +41,15 @@ const formatDate = (dateStr?: string): string => {
minute: '2-digit' minute: '2-digit'
}); });
}; };
const severityClasses: Record<string, string> = {
success: 'bg-green-100 text-green-800',
info: 'bg-blue-100 text-blue-800',
warn: 'bg-yellow-100 text-yellow-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
secondary: 'bg-gray-100 text-gray-800',
};
</script> </script>
<template> <template>
@@ -60,17 +68,17 @@ const formatDate = (dateStr?: string): string => {
<span>{{ formatDate(video.created_at) }}</span> <span>{{ formatDate(video.created_at) }}</span>
<span>{{ formatFileSize(video.size) }}</span> <span>{{ formatFileSize(video.size) }}</span>
<span>{{ formatDuration(video.duration) }}</span> <span>{{ formatDuration(video.duration) }}</span>
<Tag :value="video.status" :severity="getStatusSeverity(video.status)" <span
class="capitalize px-2 py-0.5 text-xs" /> class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
{{ video.status }}
</span>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<!-- Save Button (Edit Mode) --> <AppButton size="sm" variant="secondary"
<!-- View Mode Buttons -->
<Button size="small"
severity="secondary"
title="Reload video" @click="$emit('reload')"> title="Reload video" @click="$emit('reload')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -78,23 +86,25 @@ const formatDate = (dateStr?: string): string => {
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Reload</span> <span class="hidden sm:inline">Reload</span>
</Button> </AppButton>
<Button size="small" title="Edit" variant="outlined" @click="$emit('toggleEdit')"> <AppButton size="sm" variant="ghost"
title="Edit" @click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"> d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Edit</span> <span class="hidden sm:inline">Edit</span>
</Button> </AppButton>
<Button severity="danger" size="small" title="Delete" @click="$emit('delete')"> <AppButton variant="danger" size="sm"
title="Delete" @click="$emit('delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"> d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Delete</span> <span class="hidden sm:inline">Delete</span>
</Button> </AppButton>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,60 +1,43 @@
<script setup lang="ts">
import Skeleton from 'primevue/skeleton';
</script>
<template> <template>
<div class="flex flex-col lg:flex-row gap-4"> <div class="flex flex-col lg:flex-row gap-4">
<!-- Video Player Skeleton --> <!-- Video Player Skeleton -->
<div class="md:flex-1 aspect-video rounded-xl bg-gray-200 animate-pulse" /> <div class="md:flex-1 aspect-video rounded-xl bg-gray-200 animate-pulse" />
<!-- Info Card Skeleton --> <!-- Info Card Skeleton -->
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6"> <div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6">
<!-- Header Skeleton --> <!-- Header Skeleton -->
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="flex-1 space-y-3"> <div class="flex-1 space-y-3">
<Skeleton width="60%" height="2rem" /> <div class="w-3/5 h-8 bg-gray-200 rounded animate-pulse" />
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Skeleton width="8rem" height="1rem" /> <div class="w-32 h-4 bg-gray-200 rounded animate-pulse" />
<Skeleton width="5rem" height="1rem" /> <div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
<Skeleton width="4rem" height="1rem" /> <div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
<Skeleton width="4rem" height="1.5rem" /> <div class="w-16 h-6 bg-gray-200 rounded animate-pulse" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Skeleton width="5rem" height="2rem" /> <div class="w-20 h-8 bg-gray-200 rounded animate-pulse" />
<Skeleton width="4rem" height="2rem" /> <div class="w-16 h-8 bg-gray-200 rounded animate-pulse" />
<Skeleton width="4.5rem" height="2rem" /> <div class="w-[4.5rem] h-8 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>
</div> </div>
<!-- Content Grid Skeleton --> <!-- Content Grid Skeleton -->
<div class="grid grid-cols-1"> <div class="grid grid-cols-1">
<!-- Left Column --> <!-- Left Column -->
<div class="space-y-4"> <div class="space-y-4">
<Skeleton width="100%" height="1.5rem" /> <div class="w-full h-6 bg-gray-200 rounded animate-pulse" />
<div class="space-y-3"> <div class="space-y-3">
<div v-for="i in 6" :key="i" class="space-y-1"> <div v-for="i in 6" :key="i" class="space-y-1">
<Skeleton width="100%" height="0.875rem" /> <div class="w-full h-3.5 bg-gray-200 rounded animate-pulse" />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Skeleton width="100%" height="1.75rem" /> <div class="w-full h-7 bg-gray-200 rounded animate-pulse" />
<Skeleton width="2rem" height="1.75rem" /> <div class="w-8 h-7 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Right Column -->
<!-- <div class="space-y-4">
<Skeleton width="8rem" height="1.5rem" />
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="space-y-1">
<Skeleton width="25%" height="0.875rem" />
<Skeleton width="50%" height="1.25rem" />
</div>
</div>
<Skeleton width="6rem" height="1.5rem" class="mt-6" />
<Skeleton width="100%" height="4rem" />
</div> -->
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { getStatusSeverity } from '@/lib/utils'; const props = defineProps<{
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
defineProps<{
searchQuery: string; searchQuery: string;
selectedStatus: string; selectedStatus: string;
statusOptions: { label: string; value: string }[]; statusOptions: { label: string; value: string }[];
@@ -22,53 +16,64 @@ const emit = defineEmits<{
(e: 'update:limit', value: number): void; (e: 'update:limit', value: number): void;
(e: 'search'): void; (e: 'search'): void;
}>(); }>();
const pageCount = computed(() => Math.ceil(props.total / props.limit) || 1);
const first = computed(() => Math.min((props.page - 1) * props.limit + 1, props.total));
const last = computed(() => Math.min(props.page * props.limit, props.total));
const prevPage = () => {
if (props.page > 1) emit('update:page', props.page - 1);
};
const nextPage = () => {
if (props.page < pageCount.value) emit('update:page', props.page + 1);
};
</script> </script>
<template> <template>
<div class="border-b border-gray-200 mb-6"> <div class="border-b border-gray-200 mb-6">
<div class="flex flex-col md:flex-row gap-3 items-stretch md:items-center"> <div class="flex flex-col md:flex-row gap-3 items-stretch md:items-center">
<!-- Search --> <!-- Search -->
<IconField class="flex-1"> <AppInput :model-value="searchQuery" @update:model-value="emit('update:searchQuery', $event as string)"
<InputIcon> @enter="emit('search')" placeholder="Search videos..." class="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg> <template #prefix>
</InputIcon> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<InputText :modelValue="searchQuery" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@update:modelValue="emit('update:searchQuery', $event as string)" <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
@keyup.enter="emit('search')" placeholder="Search videos..." fluid /> </svg>
</IconField> </template>
</AppInput>
<!-- Status Filter --> <!-- Status Filter -->
<Select :modelValue="selectedStatus" @update:modelValue="emit('update:selectedStatus', $event)" <select :value="selectedStatus" @change="emit('update:selectedStatus', ($event.target as HTMLSelectElement).value)"
:options="statusOptions" optionLabel="label" optionValue="value" placeholder="Status" class="w-full md:w-44 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
class="w-full md:w-44"> <option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
<template #option="slotProps"> {{ opt.label }}
<Tag :value="slotProps.option.label" :severity="getStatusSeverity(slotProps.option.value)" </option>
class="capitalize" /> </select>
</template>
</Select>
</div> </div>
<!-- Paginator --> <!-- Paginator -->
<Paginator :pt="{ root: '!bg-transparent !p-0 !justify-end !mt-3 !mb-2' }" :rows="limit" :totalRecords="total" <div class="flex justify-end w-full gap-2 mt-3 mb-2">
:first="(page - 1) * limit" :rowsPerPageOptions="[10, 20, 30]" <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
@page="(e) => { emit('update:page', e.page + 1); emit('update:limit', e.rows); }"> {{ first }}&ndash;{{ last }} of {{ total }}
<template #container="{ first, last, page, pageCount, prevPageCallback, nextPageCallback, totalRecords }"> </span>
<div class="flex justify-end w-full gap-2"> <div class="flex items-center gap-1">
<Tag severity="secondary" size="small" rounded> <button class="p-1.5 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
{{ first }}&ndash;{{ last }} of {{ totalRecords }} @click="prevPage" :disabled="page <= 1" aria-label="Previous page">
</Tag> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<div class="flex items-center gap-1"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<Button rounded variant="text" size="small" <path d="m15 18-6-6 6-6" />
@click="prevPageCallback" :disabled="page === 0" aria-label="Previous page"> </svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg> </button>
</Button> <button class="p-1.5 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
<Button rounded variant="text" size="small" @click="nextPage" :disabled="page >= pageCount" aria-label="Next page">
@click="nextPageCallback" :disabled="page === pageCount! - 1" aria-label="Next page"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</Button> <path d="m9 18 6-6-6-6" />
</div> </svg>
</div> </button>
</template> </div>
</Paginator> </div>
</div> </div>
</template> </template>

View File

@@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelVideo } from '@/api/client'; import type { ModelVideo } from '@/api/client';
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils'; import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
import Card from 'primevue/card';
import Checkbox from 'primevue/checkbox';
import CardPopover from './CardPopover.vue'; import CardPopover from './CardPopover.vue';
defineProps<{ const props = defineProps<{
videos: ModelVideo[]; videos: ModelVideo[];
selectedVideos: ModelVideo[]; selectedVideos: ModelVideo[];
loading: boolean; loading: boolean;
@@ -15,78 +13,98 @@ const emit = defineEmits<{
(e: 'update:selectedVideos', value: ModelVideo[]): void; (e: 'update:selectedVideos', value: ModelVideo[]): void;
(e: 'delete', videoId: string): void; (e: 'delete', videoId: string): void;
}>(); }>();
const severityClasses: Record<string, string> = {
success: 'bg-green-100 text-green-800',
info: 'bg-blue-100 text-blue-800',
warn: 'bg-yellow-100 text-yellow-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
secondary: 'bg-gray-100 text-gray-800',
};
const isSelected = (video: ModelVideo) =>
props.selectedVideos.some(v => v.id === video.id);
const toggleSelection = (video: ModelVideo) => {
if (isSelected(video)) {
emit('update:selectedVideos', props.selectedVideos.filter(v => v.id !== video.id));
} else {
emit('update:selectedVideos', [...props.selectedVideos, video]);
}
};
</script> </script>
<template> <template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div v-if="loading" v-for="i in 10" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div v-if="loading" v-for="i in 10" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<Skeleton height="150px" width="100%"></Skeleton> <div class="w-full h-[150px] bg-gray-200 animate-pulse" />
<div class="p-4"> <div class="p-4">
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton> <div class="w-4/5 h-6 bg-gray-200 rounded animate-pulse mb-2" />
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton> <div class="w-3/5 h-4 bg-gray-200 rounded animate-pulse mb-4" />
<div class="flex justify-between"> <div class="flex justify-between">
<Skeleton width="3rem" height="1rem"></Skeleton> <div class="w-12 h-4 bg-gray-200 rounded animate-pulse" />
<Skeleton width="3rem" height="1rem"></Skeleton> <div class="w-12 h-4 bg-gray-200 rounded animate-pulse" />
</div> </div>
</div> </div>
</div> </div>
<Card v-for="video in videos" :key="video.id" v-else
class="overflow-hidden transition group relative border-2 border-gray-200 !shadow-none" <div v-for="video in videos" :key="video.id" v-else
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }"> class="bg-white overflow-hidden transition group relative border-2 border-gray-200 rounded-xl !shadow-none"
:class="{ '!border-primary ring-2 ring-primary': isSelected(video) }">
<!-- Header -->
<div class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
<!-- Grid Selection Checkbox -->
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
:class="{ 'opacity-100': isSelected(video) }">
<input type="checkbox" :checked="isSelected(video)" @change="toggleSelection(video)"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
</div>
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
<span class="i-heroicons-film text-3xl" />
</div>
<template #header>
<div <div
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity"> class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
<!-- Grid Selection Checkbox -->
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
:class="{ 'opacity-100': selectedVideos.some(v => v.id === video.id) }">
<Checkbox :modelValue="selectedVideos" :value="video"
@update:modelValue="emit('update:selectedVideos', $event)" />
</div>
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
<span class="i-heroicons-film text-3xl" />
</div>
<div
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
</div>
<span
class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
{{ formatDuration(video.duration) }}
</span>
</div> </div>
</template>
<template #content> <span
<div class="flex flex-col h-full"> class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
<div class="flex items-start justify-between gap-2 mb-1"> {{ formatDuration(video.duration) }}
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1" </span>
:title="video.title"> </div>
{{ video.title }}
</h3>
<button class="text-gray-400 hover:text-gray-700">
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
</button>
</div>
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }} <!-- Content -->
</p> <div class="p-4 flex flex-col h-full">
<div class="text-xs text-gray-400 mt-auto"> <div class="flex items-start justify-between gap-2 mb-1">
{{ formatDate(video.created_at) }} <h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
</div> :title="video.title">
{{ video.title }}
</h3>
<button class="text-gray-400 hover:text-gray-700">
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
</button>
</div> </div>
</template>
<template #footer> <p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
<div class="mt-auto flex items-center justify-between"> </p>
<Tag :value="video.status" :severity="getStatusSeverity(video.status)" <div class="text-xs text-gray-400 mt-auto">
class="capitalize px-2 py-0.5 text-xs" /> {{ formatDate(video.created_at) }}
<CardPopover :video="video" @delete="emit('delete', video.id || '')"/> </div>
</div> </div>
</template>
</Card> <!-- Footer -->
<div class="px-4 pb-4 mt-auto flex items-center justify-between">
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
{{ video.status }}
</span>
<CardPopover :video="video" @delete="emit('delete', video.id || '')" />
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -5,11 +5,8 @@ import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue'; import VideoIcon from '@/components/icons/VideoIcon.vue';
import { formatBytes, formatDate, getStatusSeverity } from '@/lib/utils'; import { formatBytes, formatDate, getStatusSeverity } from '@/lib/utils';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable from 'primevue/datatable';
defineProps<{ const props = defineProps<{
videos: ModelVideo[]; videos: ModelVideo[];
selectedVideos: ModelVideo[]; selectedVideos: ModelVideo[];
loading: boolean; loading: boolean;
@@ -21,6 +18,39 @@ const emit = defineEmits<{
(e: 'edit', videoId: string): void; (e: 'edit', videoId: string): void;
(e: 'copy', videoId: string): void; (e: 'copy', videoId: string): void;
}>(); }>();
const severityClasses: Record<string, string> = {
success: 'bg-green-100 text-green-800',
info: 'bg-blue-100 text-blue-800',
warn: 'bg-yellow-100 text-yellow-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
secondary: 'bg-gray-100 text-gray-800',
};
const isAllSelected = computed(() =>
props.videos.length > 0 && props.selectedVideos.length === props.videos.length
);
const toggleAll = () => {
if (isAllSelected.value) {
emit('update:selectedVideos', []);
} else {
emit('update:selectedVideos', [...props.videos]);
}
};
const toggleRow = (video: ModelVideo) => {
const exists = props.selectedVideos.some(v => v.id === video.id);
if (exists) {
emit('update:selectedVideos', props.selectedVideos.filter(v => v.id !== video.id));
} else {
emit('update:selectedVideos', [...props.selectedVideos, video]);
}
};
const isSelected = (video: ModelVideo) =>
props.selectedVideos.some(v => v.id === video.id);
</script> </script>
<template> <template>
@@ -28,83 +58,85 @@ const emit = defineEmits<{
<div v-if="loading"> <div v-if="loading">
<div class="p-4 border-b border-gray-200 last:border-b-0" v-for="i in 10" :key="i"> <div class="p-4 border-b border-gray-200 last:border-b-0" v-for="i in 10" :key="i">
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<Skeleton width="5rem" height="3rem" borderRadius="6px" /> <div class="w-20 h-12 bg-gray-200 rounded-md animate-pulse" />
<div class="flex-1"> <div class="flex-1">
<Skeleton width="40%" height="1rem" class="mb-2" /> <div class="w-2/5 h-4 bg-gray-200 rounded animate-pulse mb-2" />
<Skeleton width="25%" height="0.75rem" /> <div class="w-1/4 h-3 bg-gray-200 rounded animate-pulse" />
</div> </div>
<Skeleton width="8%" height="0.75rem" /> <div class="w-[8%] h-3 bg-gray-200 rounded animate-pulse" />
<Skeleton width="8%" height="0.75rem" /> <div class="w-[8%] h-3 bg-gray-200 rounded animate-pulse" />
<Skeleton width="4rem" height="1.5rem" borderRadius="16px" /> <div class="w-16 h-6 bg-gray-200 rounded-full animate-pulse" />
<Skeleton width="5.5rem" height="1.75rem" borderRadius="6px" /> <div class="w-22 h-7 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
</div> </div>
</div> </div>
<DataTable v-else :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos" <table v-else class="w-full min-w-[50rem]">
@update:selection="emit('update:selectedVideos', $event)"> <thead>
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column> <tr class="border-b border-gray-200 bg-gray-50">
<th class="w-12 px-4 py-3">
<Column header="Video"> <input type="checkbox" :checked="isAllSelected" @change="toggleAll"
<template #body="{ data }"> class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
<div class="flex items-center gap-3"> </th>
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0"> <th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Video</th>
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.title" <th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Status</th>
class="w-full h-full object-cover" /> <th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Size</th>
<div v-else class="w-full h-full flex items-center justify-center"> <th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Created</th>
<VideoIcon class="text-gray-400 text-xl w-5 h-5" /> <th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="data in videos" :key="data.id"
class="border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
:class="{ 'bg-primary/5': isSelected(data) }">
<td class="px-4 py-3">
<input type="checkbox" :checked="isSelected(data)" @change="toggleRow(data)"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.title"
class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<VideoIcon class="text-gray-400 text-xl w-5 h-5" />
</div>
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p>
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
</div> </div>
</div> </div>
<div class="min-w-0 flex-1"> </td>
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p> <td class="px-4 py-3">
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p> <span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(data.status) || 'secondary']">
{{ data.status }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-0.5">
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy link" @click="emit('copy', data.id!)">
<LinkIcon class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-primary transition-colors"
title="Edit" @click="emit('edit', data.id!)">
<PencilIcon class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-red-50 text-gray-500 hover:text-red-500 transition-colors"
title="Delete" @click="emit('delete', data.id!)">
<TrashIcon class="w-4 h-4" />
</button>
</div> </div>
</div> </td>
</template> </tr>
</Column> </tbody>
</table>
<Column header="Status">
<template #body="{ data }">
<Tag :value="data.status" :severity="getStatusSeverity(data.status)"
class="capitalize px-2 py-0.5 text-xs" />
</template>
</Column>
<!-- <Column header="Duration">
<template #body="{ data }">
<span class="text-sm text-gray-500">{{ formatDuration(data.duration) }}</span>
</template>
</Column> -->
<Column header="Size">
<template #body="{ data }">
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
</template>
</Column>
<Column header="Created">
<template #body="{ data }">
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
</template>
</Column>
<Column header="Actions">
<template #body="{ data }">
<div class="flex items-center gap-0.5">
<Button text rounded size="small" severity="secondary" title="Copy link"
@click="emit('copy', data.id)">
<LinkIcon class="w-4 h-4" />
</Button>
<Button text rounded size="small" title="Edit"
@click="emit('edit', data.id)">
<PencilIcon class="w-4 h-4" />
</Button>
<Button text rounded size="small" severity="danger" title="Delete"
@click="emit('delete', data.id)">
<TrashIcon class="w-4 h-4" />
</Button>
</div>
</template>
</Column>
</DataTable>
</div> </div>
</template> </template>

View File

@@ -2,18 +2,13 @@ import { serializeQueryCache } from '@pinia/colada';
import { renderSSRHead } from '@unhead/vue/server'; import { renderSSRHead } from '@unhead/vue/server';
import { streamText } from 'hono/streaming'; import { streamText } from 'hono/streaming';
import { renderToWebStream } from 'vue/server-renderer'; import { renderToWebStream } from 'vue/server-renderer';
// @ts-ignore
import Base from '@primevue/core/base';
import { createApp } from '@/main'; import { createApp } from '@/main';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { buildBootstrapScript } from '@/lib/manifest'; import { buildBootstrapScript } from '@/lib/manifest';
import { styleTags } from '@/lib/primePassthrough';
import { htmlEscape } from '@/server/utils/htmlEscape'; import { htmlEscape } from '@/server/utils/htmlEscape';
import type { Hono } from 'hono'; import type { Hono } from 'hono';
const DEFAULT_STYLE_NAMES = ['primitive', 'semantic', 'global', 'base', 'ripple-directive'];
export function registerSSRRoutes(app: Hono) { export function registerSSRRoutes(app: Hono) {
app.get("*", async (c) => { app.get("*", async (c) => {
const nonce = crypto.randomUUID(); const nonce = crypto.randomUUID();
@@ -30,9 +25,6 @@ export function registerSSRRoutes(app: Hono) {
await router.push(url.pathname); await router.push(url.pathname);
await router.isReady(); await router.isReady();
const usedStyles = new Set<string>();
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name);
return streamText(c, async (stream) => { return streamText(c, async (stream) => {
c.header("Content-Type", "text/html; charset=utf-8"); c.header("Content-Type", "text/html; charset=utf-8");
c.header("Content-Encoding", "Identity"); c.header("Content-Encoding", "Identity");
@@ -56,19 +48,6 @@ export function registerSSRRoutes(app: Hono) {
// Bootstrap scripts // Bootstrap scripts
await stream.write(buildBootstrapScript()); await stream.write(buildBootstrapScript());
// PrimeVue styles
if (usedStyles.size > 0) {
DEFAULT_STYLE_NAMES.forEach(name => usedStyles.add(name));
}
const activeStyles = styleTags.filter(tag =>
usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))
);
for (const tag of activeStyles) {
await stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`);
}
// Body start // Body start
await stream.write(`</head><body class='${bodyClass}'>`); await stream.write(`</head><body class='${bodyClass}'>`);

View File

@@ -232,11 +232,6 @@ export default defineConfig({
body { body {
scrollbar-gutter: stable !important; scrollbar-gutter: stable !important;
} }
/* Prevent layout shift when PrimeVue dialogs open */
body.p-overflow-hidden {
overflow: hidden !important;
padding-right: 0 !important;
}
:root { :root {
--font-sans: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-sans: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Playfair Display', serif, 'Times New Roman', Times, serif; --font-serif: 'Playfair Display', serif, 'Times New Roman', Times, serif;

View File

@@ -1,5 +1,4 @@
import { cloudflare } from "@cloudflare/vite-plugin"; import { cloudflare } from "@cloudflare/vite-plugin";
import { PrimeVueResolver } from "@primevue/auto-import-resolver";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx"; import vueJsx from "@vitejs/plugin-vue-jsx";
import path from "node:path"; import path from "node:path";
@@ -25,7 +24,6 @@ export default defineConfig((env) => {
dts: true, dts: true,
dtsTsx: true, dtsTsx: true,
directives: false, directives: false,
resolvers: [PrimeVueResolver()],
}), }),
ssrPlugin(), ssrPlugin(),
cloudflare(), cloudflare(),