develop-updateui #1
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
bun.lock
26
bun.lock
@@ -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
18
components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
37
src/main.ts
37
src/main.ts
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = () => {
|
||||||
|
errors.email = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.auth.forgotPasswordCreate({ email: form.email })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
|
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
|
||||||
});
|
});
|
||||||
// forgotPassword(values.email).then(() => {
|
|
||||||
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
|
||||||
// }).catch(() => {
|
|
||||||
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -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,57 +58,68 @@
|
|||||||
</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({
|
|
||||||
|
const schema = z.object({
|
||||||
email: z.string().min(1, { message: 'Email or username is required.' }),
|
email: z.string().min(1, { message: 'Email or username is required.' }),
|
||||||
password: z.string().min(1, { message: 'Password is required.' })
|
password: z.string().min(1, { message: 'Password is required.' })
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
|
watch(() => auth.error, (newError) => {
|
||||||
if (valid) auth.login(values.email, values.password);
|
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 = () => {
|
||||||
|
|||||||
@@ -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({
|
|
||||||
|
const schema = z.object({
|
||||||
name: z.string().min(1, { message: 'Name is required.' }),
|
name: z.string().min(1, { message: 'Name is required.' }),
|
||||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
|
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.' })
|
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
const onFormSubmit = () => {
|
||||||
if (valid) {
|
errors.name = undefined;
|
||||||
auth.register(values.name, values.email, values.password);
|
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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +43,14 @@ 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"
|
||||||
|
>
|
||||||
|
<template #header="{ close }">
|
||||||
<!-- ── Header ── -->
|
<!-- ── Header ── -->
|
||||||
<div class="flex items-center justify-between px-6 py-5 border-b border-slate-100">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
|
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24"
|
||||||
@@ -79,9 +79,10 @@ const handleStartUpload = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ── Input area ── -->
|
<!-- ── Input area ── -->
|
||||||
<div class="p-5" style="height: 320px;">
|
<div class="h-[320px]">
|
||||||
<!-- Queue full warning -->
|
<!-- Queue full warning -->
|
||||||
<div v-if="remainingSlots === 0"
|
<div v-if="remainingSlots === 0"
|
||||||
class="h-full flex flex-col items-center justify-center gap-4 text-center">
|
class="h-full flex flex-col items-center justify-center gap-4 text-center">
|
||||||
@@ -112,8 +113,9 @@ const handleStartUpload = () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
<!-- ── Footer ── -->
|
<!-- ── Footer ── -->
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-slate-400">
|
<span class="text-sm text-slate-400">
|
||||||
<span v-if="remainingSlots < maxItems">
|
<span v-if="remainingSlots < maxItems">
|
||||||
<span class="font-semibold"
|
<span class="font-semibold"
|
||||||
@@ -123,7 +125,7 @@ const handleStartUpload = () => {
|
|||||||
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
|
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="closeCallback"
|
<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">
|
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
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -140,7 +142,6 @@ const handleStartUpload = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -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 https://example.com/video.mp4 https://drive.google.com/..."
|
placeholder="Paste video URLs here, one per line https://example.com/video.mp4 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -170,5 +159,5 @@ watch(() => props.videoId, (newId) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<!-- Footer skeleton when loading -->
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</form>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
leave-active-class="transition duration-75 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||||
|
<div v-if="isOpen" ref="menuRef"
|
||||||
|
class="fixed z-50 min-w-[160px] bg-white rounded-lg border border-gray-200 shadow-lg py-1"
|
||||||
|
:style="menuStyle">
|
||||||
|
<template v-for="(item, index) in items" :key="index">
|
||||||
|
<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>
|
</router-link>
|
||||||
<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">
|
<button v-else type="button"
|
||||||
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" />
|
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm w-full text-left"
|
||||||
<span :class="(item as any).labelClass">{{ item.label }}</span>
|
@click="item.command?.(); isOpen = false">
|
||||||
</a>
|
<component :is="item.icon" class="w-4 h-4" :class="item.iconClass" />
|
||||||
|
<span :class="item.labelClass">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Menu>
|
</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);
|
||||||
@@ -132,8 +168,4 @@ const items = shallowRef<CustomMenuItem[]>([
|
|||||||
command: handleDelete
|
command: handleDelete
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggle = (event: Event) => {
|
|
||||||
menu.value?.toggle(event);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
<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 -->
|
||||||
@@ -12,17 +8,17 @@ import Skeleton from 'primevue/skeleton';
|
|||||||
<!-- 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>
|
||||||
@@ -31,30 +27,17 @@ import Skeleton from 'primevue/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>
|
||||||
|
|||||||
@@ -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 }}–{{ last }} of {{ total }}
|
||||||
<template #container="{ first, last, page, pageCount, prevPageCallback, nextPageCallback, totalRecords }">
|
</span>
|
||||||
<div class="flex justify-end w-full gap-2">
|
|
||||||
<Tag severity="secondary" size="small" rounded>
|
|
||||||
{{ first }}–{{ last }} of {{ totalRecords }}
|
|
||||||
</Tag>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button rounded variant="text" size="small"
|
<button class="p-1.5 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
@click="prevPageCallback" :disabled="page === 0" aria-label="Previous page">
|
@click="prevPage" :disabled="page <= 1" aria-label="Previous page">
|
||||||
<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>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
</Button>
|
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="nextPageCallback" :disabled="page === pageCount! - 1" aria-label="Next 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="m9 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"
|
||||||
|
@click="nextPage" :disabled="page >= pageCount" aria-label="Next page">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Paginator>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|||||||
@@ -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,33 +13,53 @@ 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"
|
|
||||||
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
|
||||||
|
|
||||||
<template #header>
|
<div v-for="video in videos" :key="video.id" v-else
|
||||||
<div
|
class="bg-white overflow-hidden transition group relative border-2 border-gray-200 rounded-xl !shadow-none"
|
||||||
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
|
: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 -->
|
<!-- Grid Selection Checkbox -->
|
||||||
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
<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) }">
|
:class="{ 'opacity-100': isSelected(video) }">
|
||||||
<Checkbox :modelValue="selectedVideos" :value="video"
|
<input type="checkbox" :checked="isSelected(video)" @change="toggleSelection(video)"
|
||||||
@update:modelValue="emit('update:selectedVideos', $event)" />
|
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||||
@@ -59,10 +77,9 @@ const emit = defineEmits<{
|
|||||||
{{ formatDuration(video.duration) }}
|
{{ formatDuration(video.duration) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
<!-- Content -->
|
||||||
<div class="flex flex-col h-full">
|
<div class="p-4 flex flex-col h-full">
|
||||||
<div class="flex items-start justify-between gap-2 mb-1">
|
<div class="flex items-start justify-between gap-2 mb-1">
|
||||||
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
|
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
|
||||||
:title="video.title">
|
:title="video.title">
|
||||||
@@ -79,14 +96,15 @@ const emit = defineEmits<{
|
|||||||
{{ formatDate(video.created_at) }}
|
{{ formatDate(video.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<template #footer>
|
<!-- Footer -->
|
||||||
<div class="mt-auto flex items-center justify-between">
|
<div class="px-4 pb-4 mt-auto flex items-center justify-between">
|
||||||
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
|
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
class="capitalize px-2 py-0.5 text-xs" />
|
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
|
||||||
|
{{ video.status }}
|
||||||
|
</span>
|
||||||
<CardPopover :video="video" @delete="emit('delete', video.id || '')" />
|
<CardPopover :video="video" @delete="emit('delete', video.id || '')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,24 +58,41 @@ 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" />
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Video</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Size</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Created</th>
|
||||||
|
<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="flex items-center gap-3">
|
||||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
<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"
|
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.title"
|
||||||
@@ -59,52 +106,37 @@ const emit = defineEmits<{
|
|||||||
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
|
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</td>
|
||||||
</Column>
|
<td class="px-4 py-3">
|
||||||
|
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
<Column header="Status">
|
:class="severityClasses[getStatusSeverity(data.status) || 'secondary']">
|
||||||
<template #body="{ data }">
|
{{ data.status }}
|
||||||
<Tag :value="data.status" :severity="getStatusSeverity(data.status)"
|
</span>
|
||||||
class="capitalize px-2 py-0.5 text-xs" />
|
</td>
|
||||||
</template>
|
<td class="px-4 py-3">
|
||||||
</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>
|
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
||||||
</template>
|
</td>
|
||||||
</Column>
|
<td class="px-4 py-3">
|
||||||
|
|
||||||
<Column header="Created">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
|
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
|
||||||
</template>
|
</td>
|
||||||
</Column>
|
<td class="px-4 py-3">
|
||||||
|
|
||||||
<Column header="Actions">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<Button text rounded size="small" severity="secondary" title="Copy link"
|
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
@click="emit('copy', data.id)">
|
title="Copy link" @click="emit('copy', data.id!)">
|
||||||
<LinkIcon class="w-4 h-4" />
|
<LinkIcon class="w-4 h-4" />
|
||||||
</Button>
|
</button>
|
||||||
<Button text rounded size="small" title="Edit"
|
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-primary transition-colors"
|
||||||
@click="emit('edit', data.id)">
|
title="Edit" @click="emit('edit', data.id!)">
|
||||||
<PencilIcon class="w-4 h-4" />
|
<PencilIcon class="w-4 h-4" />
|
||||||
</Button>
|
</button>
|
||||||
<Button text rounded size="small" severity="danger" title="Delete"
|
<button class="p-1.5 rounded-md hover:bg-red-50 text-gray-500 hover:text-red-500 transition-colors"
|
||||||
@click="emit('delete', data.id)">
|
title="Delete" @click="emit('delete', data.id!)">
|
||||||
<TrashIcon class="w-4 h-4" />
|
<TrashIcon class="w-4 h-4" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</td>
|
||||||
</Column>
|
</tr>
|
||||||
</DataTable>
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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}'>`);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user