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

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

View File

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

View File

@@ -6,8 +6,6 @@
"name": "holistream",
"dependencies": {
"@pinia/colada": "^0.21.2",
"@primeuix/themes": "^2.0.3",
"@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.2.0",
"aws4fetch": "^1.0.20",
@@ -15,7 +13,6 @@
"hono": "^4.11.7",
"is-mobile": "^5.0.0",
"pinia": "^3.0.4",
"primevue": "^4.5.4",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.27",
"vue-router": "^5.0.2",
@@ -23,7 +20,6 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.23.0",
"@primevue/auto-import-resolver": "^4.5.4",
"@types/node": "^25.2.0",
"@vitejs/plugin-vue": "^6.0.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=="],
"@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=="],
"@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=="],
"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=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],

18
components.d.ts vendored
View File

@@ -28,9 +28,7 @@ declare module 'vue' {
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
Bell: typeof import('./src/components/icons/Bell.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']
Checkbox: typeof import('primevue/checkbox')['default']
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/icons/CheckIcon.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']
DashboardLayout: typeof import('./src/components/DashboardLayout.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']
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.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']
ImageIcon: typeof import('./src/components/icons/ImageIcon.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']
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
LockIcon: typeof import('./src/components/icons/LockIcon.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']
NotificationDrawer: typeof import('./src/components/NotificationDrawer.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']
Password: typeof import('primevue/password')['default']
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
@@ -76,10 +69,8 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SendIcon: typeof import('./src/components/icons/SendIcon.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']
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
Tag: typeof import('primevue/tag')['default']
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
TestIcon: typeof import('./src/components/icons/TestIcon.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 Bell: typeof import('./src/components/icons/Bell.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 Checkbox: typeof import('primevue/checkbox')['default']
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
const CheckIcon: typeof import('./src/components/icons/CheckIcon.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 DashboardLayout: typeof import('./src/components/DashboardLayout.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 EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.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 ImageIcon: typeof import('./src/components/icons/ImageIcon.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 LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
const LockIcon: typeof import('./src/components/icons/LockIcon.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 NotificationDrawer: typeof import('./src/components/NotificationDrawer.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 Password: typeof import('primevue/password')['default']
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
const PlayIcon: typeof import('./src/components/icons/PlayIcon.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 SendIcon: typeof import('./src/components/icons/SendIcon.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 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 TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']

View File

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

View File

@@ -27,6 +27,7 @@ const links = [
];
//v-tooltip="i.label"
</script>
<template>
@@ -35,7 +36,8 @@ const links = [
<template v-for="i in links" :key="i.label">
<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(
i.className,
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
@@ -45,7 +47,5 @@ const links = [
</component>
</template>
</header>
<ClientOnly>
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
</ClientOnly>
</template>

View File

@@ -1,7 +1,13 @@
<script setup lang="ts">
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
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
const emit = defineEmits(['change']);
@@ -121,7 +127,7 @@ defineExpose({ toggle });
</script>
<template>
<Teleport to="body">
<Teleport v-if="isMounted" to="body">
<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"
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"

View File

@@ -1,7 +1,13 @@
<script setup lang="ts">
import XIcon from '@/components/icons/XIcon.vue';
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<{
visible: boolean;
@@ -47,7 +53,7 @@ onBeforeUnmount(() => {
</script>
<template>
<Teleport to="body">
<Teleport v-if="isMounted" to="body">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0"
@@ -67,7 +73,12 @@ onBeforeUnmount(() => {
<!-- Panel -->
<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="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">
{{ title }}
</h3>
@@ -82,10 +93,12 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- Content -->
<div class="p-5">
<slot />
</div>
<!-- Footer slot -->
<div v-if="$slots.footer" class="px-5 py-4 border-t border-border bg-muted/20">
<slot name="footer" />
</div>

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +1,12 @@
import { PiniaColada, useQueryCache } from '@pinia/colada';
import { definePreset } from '@primeuix/themes';
import Aura from '@primeuix/themes/aura';
import { createHead as CSRHead } from "@unhead/vue/client";
import { createHead as SSRHead } from "@unhead/vue/server";
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 { RouterView } from 'vue-router';
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
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"
export function createApp() {
const pinia = createPinia();
@@ -38,24 +14,11 @@ export function createApp() {
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
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', {
created(el) {
el.__v_skip = true;
}
});
app.directive("tooltip", Tooltip)
app.use(pinia);
app.use(PiniaColada, {
pinia,

View File

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

View File

@@ -1,27 +1,41 @@
<template>
<div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<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" />
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{
$form.email.error?.message }}</Message>
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" placeholder="Enter your password" :feedback="false" toggleMask
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" />
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{
$form.password.error?.message }}</Message>
<div class="relative">
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
placeholder="Enter your password" :disabled="auth.loading" />
<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 class="flex items-center justify-between">
<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>
</div>
<div class="text-sm">
@@ -31,8 +45,9 @@
</div>
</div>
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid
:loading="auth.loading" />
<AppButton type="submit" :loading="auth.loading" class="w-full">
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
</AppButton>
<div class="relative">
<div class="absolute inset-0 flex items-center">
@@ -43,57 +58,68 @@
</div>
</div>
<Button type="button" variant="outlined" severity="secondary"
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
<AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2"
@click="loginWithGoogle" :disabled="auth.loading">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
</svg>
Google
</Button>
</AppButton>
<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">
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>
<!-- <router-link to="/forgot" class="text-blue-600 hover:text-blue-500 hover:underline">Forgot password?</router-link> -->
</div>
</Form>
</form>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import Toast from 'primevue/toast';
import { useToast } from "primevue/usetoast";
import { reactive } from 'vue';
import { useAppToast } from '@/composables/useAppToast';
import { reactive, ref } from 'vue';
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: '',
password: '',
rememberMe: false
});
const resolver = zodResolver(
z.object({
const errors = reactive<{ email?: string; password?: string }>({});
const schema = z.object({
email: z.string().min(1, { message: 'Email or username is required.' }),
password: z.string().min(1, { message: 'Password is required.' })
})
);
});
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
if (valid) auth.login(values.email, values.password);
watch(() => auth.error, (newError) => {
if (newError) {
toast.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
}
});
const onFormSubmit = () => {
errors.email = undefined;
errors.password = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
}
auth.login(form.email, form.password);
};
const loginWithGoogle = () => {

View File

@@ -1,70 +1,89 @@
<template>
<div class="w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
<InputText name="name" placeholder="John Doe" fluid />
<Message v-if="$form.name?.invalid" severity="error" variant="simple">{{
$form.name.error?.message }}</Message>
<AppInput id="name" v-model="form.name" placeholder="John Doe" />
<p v-if="errors.name" class="text-xs text-red-500 mt-0.5">{{ errors.name }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText name="email" type="email" placeholder="you@example.com" fluid />
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{
$form.email.error?.message }}</Message>
<AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" placeholder="Create a password" :feedback="true" toggleMask fluid
:inputStyle="{ width: '100%' }" />
<div class="relative">
<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>
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{
$form.password.error?.message }}</Message>
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</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">
Already have an account?
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign
in</router-link>
<router-link to="/login"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign in</router-link>
</p>
</Form>
</form>
</div>
</template>
<script setup lang="ts">
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import { reactive } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { reactive, ref } from 'vue';
import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const showPassword = ref(false);
const initialValues = reactive({
const form = reactive({
name: '',
email: '',
password: ''
});
const resolver = zodResolver(
z.object({
const errors = reactive<{ name?: string; email?: string; password?: string }>({});
const schema = z.object({
name: z.string().min(1, { message: 'Name is required.' }),
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
})
);
});
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
auth.register(values.name, values.email, values.password);
const onFormSubmit = () => {
errors.name = undefined;
errors.email = undefined;
errors.password = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
}
auth.register(form.name, form.email, form.password);
};
</script>

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
<p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from
referred users!</p>
<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">
<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"

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils';
import Skeleton from 'primevue/skeleton';
interface Props {
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 class="flex items-center justify-between mb-4">
<div class="space-y-2">
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
<Skeleton width="8rem" height="2rem"></Skeleton>
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
<div class="w-32 h-8 bg-gray-200 rounded animate-pulse" />
</div>
<!-- <Skeleton shape="circle" size="3rem"></Skeleton> -->
</div>
<Skeleton width="4rem" height="1rem"></Skeleton>
<div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
</div>
</div>

View File

@@ -1,13 +1,11 @@
<script setup lang="ts">
import { useUploadQueue } from '@/composables/useUploadQueue';
import { useUIState } from '@/stores/uiState';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
import RemoteUrlForm from './components/RemoteUrlForm.vue';
import UploadDropzone from './components/UploadDropzone.vue';
const uiState = useUIState();
const toast = useToast();
const mode = ref<'local' | 'remote'>('local');
const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue();
@@ -15,7 +13,7 @@ const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxIt
const handleFilesSelected = (files: FileList) => {
const result = addFiles(files);
if (result.duplicates > 0) {
toast.add({
uiState.toastQueue.push({
severity: 'warn',
summary: 'Duplicate files skipped',
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 result = addRemoteUrls(urls);
if (result.duplicates > 0) {
toast.add({
uiState.toastQueue.push({
severity: 'warn',
summary: 'Duplicate URLs skipped',
detail: `${result.duplicates} URL${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
@@ -45,12 +43,14 @@ const handleStartUpload = () => {
</script>
<template>
<Dialog v-model:visible="uiState.uploadDialogVisible" modal dismissableMask :style="{ width: '580px', maxWidth: '96vw' }">
<template #container="{ closeCallback }">
<div class="flex flex-col bg-white rounded-2xl overflow-hidden shadow-2xl">
<AppDialog
v-model:visible="uiState.uploadDialogVisible"
:closable="false"
max-width-class="max-w-[580px] w-full"
>
<template #header="{ close }">
<!-- 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="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"
@@ -79,9 +79,10 @@ const handleStartUpload = () => {
</button>
</div>
</div>
</template>
<!-- Input area -->
<div class="p-5" style="height: 320px;">
<div class="h-[320px]">
<!-- Queue full warning -->
<div v-if="remainingSlots === 0"
class="h-full flex flex-col items-center justify-center gap-4 text-center">
@@ -112,8 +113,9 @@ const handleStartUpload = () => {
</Transition>
</div>
<template #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 v-if="remainingSlots < maxItems">
<span class="font-semibold"
@@ -123,7 +125,7 @@ const handleStartUpload = () => {
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
</span>
<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">
Close
</button>
@@ -140,7 +142,6 @@ const handleStartUpload = () => {
</button>
</div>
</div>
</div>
</template>
</Dialog>
</AppDialog>
</template>

View File

@@ -23,29 +23,49 @@ const handleSubmit = () => {
<template>
<div class="flex flex-col gap-3 h-full">
<div class="relative flex-1">
<textarea v-model="urls"
<textarea
v-model="urls"
placeholder="Paste video URLs here, one per line&#10;&#10;https://example.com/video.mp4&#10;https://drive.google.com/..."
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
transition-all resize-none text-base text-slate-700 placeholder:text-slate-300
leading-relaxed font-[inherit]"></textarea>
leading-relaxed font-[inherit]"
/>
</div>
<div class="flex items-center justify-between">
<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"
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"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
Google Drive, Dropbox supported
</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
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">
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"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { fetchMockVideoById } from '@/mocks/videos';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Skeleton from 'primevue/skeleton';
import { useToast } from 'primevue/usetoast';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref, watch } from 'vue';
const props = defineProps<{
@@ -16,7 +12,7 @@ const emit = defineEmits<{
(e: 'close'): void;
}>();
const toast = useToast();
const toast = useAppToast();
const video = ref<ModelVideo | null>(null);
const loading = ref(true);
const copiedField = ref<string | null>(null);
@@ -90,31 +86,24 @@ watch(() => props.videoId, (newId) => {
</script>
<template>
<Dialog :visible="!!videoId" @update:visible="emit('close')" modal dismissableMask
:style="{ width: '600px', maxWidth: '90vw' }">
<!-- 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>
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:title="loading ? '' : 'Get sharing address'">
<!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-5">
<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">
<Skeleton width="40%" height="0.75rem" />
<div class="w-2/5 h-3 bg-gray-200 rounded animate-pulse" />
<div class="flex gap-2">
<Skeleton width="100%" height="2.25rem" borderRadius="6px" />
<Skeleton width="2.75rem" height="2.25rem" borderRadius="6px" />
<div class="w-full h-9 bg-gray-200 rounded-md animate-pulse" />
<div class="w-11 h-9 bg-gray-200 rounded-md animate-pulse" />
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<Skeleton width="100%" height="4rem" borderRadius="6px" />
<Skeleton width="100%" height="4rem" borderRadius="6px" />
<div class="w-full h-16 bg-gray-200 rounded-md animate-pulse" />
<div class="w-full h-16 bg-gray-200 rounded-md animate-pulse" />
</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">
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
<div class="flex gap-2">
<InputText :value="link.value || ''" :placeholder="link.placeholder" readonly
class="flex-1 !font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" />
<Button severity="secondary" outlined :disabled="!link.value || copiedField === link.key"
<AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly
input-class="!font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" />
<AppButton variant="secondary" :disabled="!link.value || copiedField === link.key"
@click="copyToClipboard(link.value, link.key)" class="shrink-0">
<!-- Copy icon -->
<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">
<path d="M20 6 9 17l-5-5" />
</svg>
</Button>
</AppButton>
</div>
<p v-if="link.hint" class="text-xs text-muted-foreground">{{ link.hint }}</p>
</div>
@@ -170,5 +159,5 @@ watch(() => props.videoId, (newId) => {
</div>
</div>
</div>
</Dialog>
</AppDialog>
</template>

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,37 @@
<template>
<div class="card flex justify-center">
<Button type="button" class="!border-none" @click="toggle" severity="secondary" variant="text" aria-haspopup="true" aria-controls="overlay_menu">
<div class="relative" ref="containerRef">
<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" />
</Button>
<Menu ref="menu" id="overlay_menu" :model="items as any" :popup="true" class="min-w-[160px]">
<template #item="{ item, props }">
<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">
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" />
<span :class="(item as any).labelClass">{{ item.label }}</span>
</button>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-40" @click="isOpen = false" />
<Transition enter-active-class="transition duration-100 ease-out"
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>
<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">
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" />
<span :class="(item as any).labelClass">{{ item.label }}</span>
</a>
<button v-else type="button"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm w-full text-left"
@click="item.command?.(); isOpen = false">
<component :is="item.icon" class="w-4 h-4" :class="item.iconClass" />
<span :class="item.labelClass">{{ item.label }}</span>
</button>
</template>
</Menu>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -27,9 +43,8 @@ import PencilIcon from "@/components/icons/PencilIcon.vue";
import TrashIcon from "@/components/icons/TrashIcon.vue";
import EllipsisVerticalIcon from "@/components/icons/EllipsisVerticalIcon.vue";
import type { ModelVideo } from '@/api/client';
import { useToast } from "primevue/usetoast";
import Menu from "primevue/menu";
import { computed, ref, shallowRef } from "vue";
import { useAppToast } from "@/composables/useAppToast";
import { computed, nextTick, ref, shallowRef } from "vue";
import type { RouteLocationRaw } from "vue-router";
const props = defineProps<{
@@ -40,13 +55,34 @@ const emit = defineEmits<{
(e: 'delete'): void;
}>();
const toast = useToast();
const menu = ref<InstanceType<typeof Menu>>();
const toast = useAppToast();
const isOpen = ref(false);
const containerRef = ref<HTMLElement>();
const menuRef = ref<HTMLElement>();
const menuStyle = ref<Record<string, string>>({});
const videoUrl = computed(() => {
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 () => {
try {
await navigator.clipboard.writeText(videoUrl.value);
@@ -132,8 +168,4 @@ const items = shallowRef<CustomMenuItem[]>([
command: handleDelete
}
]);
const toggle = (event: Event) => {
menu.value?.toggle(event);
};
</script>

View File

@@ -30,7 +30,7 @@ const emit = defineEmits<{
@input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea>
</div>
<div class="float-right flex gap-2">
<Button size="small"
<AppButton size="sm"
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">
<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"
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>
</Button>
</AppButton>
<!-- Cancel Button (Edit Mode) -->
<Button severity="danger" size="small" title="Cancel editing"
<AppButton variant="danger" size="sm" title="Cancel editing"
@click="$emit('toggleEdit')">
<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>
</svg>
<span class="hidden sm:inline">Cancel</span>
</Button>
</AppButton>
</div>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { getStatusSeverity } from '@/lib/utils';
import Tag from 'primevue/tag';
const props = defineProps<{
video: ModelVideo;
@@ -42,6 +41,15 @@ const formatDate = (dateStr?: string): string => {
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>
<template>
@@ -60,17 +68,17 @@ const formatDate = (dateStr?: string): string => {
<span>{{ formatDate(video.created_at) }}</span>
<span>{{ formatFileSize(video.size) }}</span>
<span>{{ formatDuration(video.duration) }}</span>
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
class="capitalize px-2 py-0.5 text-xs" />
<span
class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
{{ video.status }}
</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-2">
<!-- Save Button (Edit Mode) -->
<!-- View Mode Buttons -->
<Button size="small"
severity="secondary"
<AppButton size="sm" variant="secondary"
title="Reload video" @click="$emit('reload')">
<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"
@@ -78,23 +86,25 @@ const formatDate = (dateStr?: string): string => {
</path>
</svg>
<span class="hidden sm:inline">Reload</span>
</Button>
<Button size="small" title="Edit" variant="outlined" @click="$emit('toggleEdit')">
</AppButton>
<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">
<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">
</path>
</svg>
<span class="hidden sm:inline">Edit</span>
</Button>
<Button severity="danger" size="small" title="Delete" @click="$emit('delete')">
</AppButton>
<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">
<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">
</path>
</svg>
<span class="hidden sm:inline">Delete</span>
</Button>
</AppButton>
</div>
</div>
</template>

View File

@@ -1,7 +1,3 @@
<script setup lang="ts">
import Skeleton from 'primevue/skeleton';
</script>
<template>
<div class="flex flex-col lg:flex-row gap-4">
<!-- Video Player Skeleton -->
@@ -12,17 +8,17 @@ import Skeleton from 'primevue/skeleton';
<!-- Header Skeleton -->
<div class="flex items-start justify-between mb-4">
<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">
<Skeleton width="8rem" height="1rem" />
<Skeleton width="5rem" height="1rem" />
<Skeleton width="4rem" height="1rem" />
<Skeleton width="4rem" height="1.5rem" />
<div class="w-32 h-4 bg-gray-200 rounded animate-pulse" />
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
<div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
<div class="w-16 h-6 bg-gray-200 rounded animate-pulse" />
</div>
<div class="flex items-center gap-2">
<Skeleton width="5rem" height="2rem" />
<Skeleton width="4rem" height="2rem" />
<Skeleton width="4.5rem" height="2rem" />
<div class="w-20 h-8 bg-gray-200 rounded animate-pulse" />
<div class="w-16 h-8 bg-gray-200 rounded animate-pulse" />
<div class="w-[4.5rem] h-8 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
@@ -31,30 +27,17 @@ import Skeleton from 'primevue/skeleton';
<div class="grid grid-cols-1">
<!-- Left Column -->
<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 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">
<Skeleton width="100%" height="1.75rem" />
<Skeleton width="2rem" height="1.75rem" />
<div class="w-full h-7 bg-gray-200 rounded animate-pulse" />
<div class="w-8 h-7 bg-gray-200 rounded animate-pulse" />
</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>

View File

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

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
import Card from 'primevue/card';
import Checkbox from 'primevue/checkbox';
import CardPopover from './CardPopover.vue';
defineProps<{
const props = defineProps<{
videos: ModelVideo[];
selectedVideos: ModelVideo[];
loading: boolean;
@@ -15,33 +13,53 @@ const emit = defineEmits<{
(e: 'update:selectedVideos', value: ModelVideo[]): 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>
<template>
<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">
<Skeleton height="150px" width="100%"></Skeleton>
<div class="w-full h-[150px] bg-gray-200 animate-pulse" />
<div class="p-4">
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
<div class="w-4/5 h-6 bg-gray-200 rounded animate-pulse mb-2" />
<div class="w-3/5 h-4 bg-gray-200 rounded animate-pulse mb-4" />
<div class="flex justify-between">
<Skeleton width="3rem" height="1rem"></Skeleton>
<Skeleton width="3rem" height="1rem"></Skeleton>
<div class="w-12 h-4 bg-gray-200 rounded animate-pulse" />
<div class="w-12 h-4 bg-gray-200 rounded animate-pulse" />
</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
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
<div v-for="video in videos" :key="video.id" v-else
class="bg-white overflow-hidden transition group relative border-2 border-gray-200 rounded-xl !shadow-none"
:class="{ '!border-primary ring-2 ring-primary': isSelected(video) }">
<!-- Header -->
<div class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
<!-- Grid Selection Checkbox -->
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
:class="{ 'opacity-100': selectedVideos.some(v => v.id === video.id) }">
<Checkbox :modelValue="selectedVideos" :value="video"
@update:modelValue="emit('update:selectedVideos', $event)" />
:class="{ 'opacity-100': isSelected(video) }">
<input type="checkbox" :checked="isSelected(video)" @change="toggleSelection(video)"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
</div>
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
@@ -59,10 +77,9 @@ const emit = defineEmits<{
{{ formatDuration(video.duration) }}
</span>
</div>
</template>
<template #content>
<div class="flex flex-col h-full">
<!-- Content -->
<div class="p-4 flex flex-col h-full">
<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"
:title="video.title">
@@ -79,14 +96,15 @@ const emit = defineEmits<{
{{ formatDate(video.created_at) }}
</div>
</div>
</template>
<template #footer>
<div class="mt-auto flex items-center justify-between">
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
class="capitalize px-2 py-0.5 text-xs" />
<!-- Footer -->
<div class="px-4 pb-4 mt-auto flex items-center justify-between">
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
{{ video.status }}
</span>
<CardPopover :video="video" @delete="emit('delete', video.id || '')" />
</div>
</template>
</Card>
</div>
</div>
</template>

View File

@@ -5,11 +5,8 @@ import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue';
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[];
selectedVideos: ModelVideo[];
loading: boolean;
@@ -21,6 +18,39 @@ const emit = defineEmits<{
(e: 'edit', 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>
<template>
@@ -28,24 +58,41 @@ const emit = defineEmits<{
<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="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">
<Skeleton width="40%" height="1rem" class="mb-2" />
<Skeleton width="25%" height="0.75rem" />
<div class="w-2/5 h-4 bg-gray-200 rounded animate-pulse mb-2" />
<div class="w-1/4 h-3 bg-gray-200 rounded animate-pulse" />
</div>
<Skeleton width="8%" height="0.75rem" />
<Skeleton width="8%" height="0.75rem" />
<Skeleton width="4rem" height="1.5rem" borderRadius="16px" />
<Skeleton width="5.5rem" height="1.75rem" borderRadius="6px" />
<div class="w-[8%] h-3 bg-gray-200 rounded animate-pulse" />
<div class="w-[8%] h-3 bg-gray-200 rounded animate-pulse" />
<div class="w-16 h-6 bg-gray-200 rounded-full animate-pulse" />
<div class="w-22 h-7 bg-gray-200 rounded-md animate-pulse" />
</div>
</div>
</div>
<DataTable v-else :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos"
@update:selection="emit('update:selectedVideos', $event)">
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
<Column header="Video">
<template #body="{ data }">
<table v-else class="w-full min-w-[50rem]">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
<th class="w-12 px-4 py-3">
<input type="checkbox" :checked="isAllSelected" @change="toggleAll"
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="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
<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>
</div>
</div>
</template>
</Column>
<Column header="Status">
<template #body="{ data }">
<Tag :value="data.status" :severity="getStatusSeverity(data.status)"
class="capitalize px-2 py-0.5 text-xs" />
</template>
</Column>
<!-- <Column header="Duration">
<template #body="{ data }">
<span class="text-sm text-gray-500">{{ formatDuration(data.duration) }}</span>
</template>
</Column> -->
<Column header="Size">
<template #body="{ data }">
</td>
<td class="px-4 py-3">
<span class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(data.status) || 'secondary']">
{{ data.status }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
</template>
</Column>
<Column header="Created">
<template #body="{ data }">
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
</template>
</Column>
<Column header="Actions">
<template #body="{ data }">
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-0.5">
<Button text rounded size="small" severity="secondary" title="Copy link"
@click="emit('copy', data.id)">
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy link" @click="emit('copy', data.id!)">
<LinkIcon class="w-4 h-4" />
</Button>
<Button text rounded size="small" title="Edit"
@click="emit('edit', data.id)">
</button>
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-primary transition-colors"
title="Edit" @click="emit('edit', data.id!)">
<PencilIcon class="w-4 h-4" />
</Button>
<Button text rounded size="small" severity="danger" title="Delete"
@click="emit('delete', data.id)">
</button>
<button class="p-1.5 rounded-md hover:bg-red-50 text-gray-500 hover:text-red-500 transition-colors"
title="Delete" @click="emit('delete', data.id!)">
<TrashIcon class="w-4 h-4" />
</Button>
</div>
</template>
</Column>
</DataTable>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

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

View File

@@ -232,11 +232,6 @@ export default defineConfig({
body {
scrollbar-gutter: stable !important;
}
/* Prevent layout shift when PrimeVue dialogs open */
body.p-overflow-hidden {
overflow: hidden !important;
padding-right: 0 !important;
}
: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-serif: 'Playfair Display', serif, 'Times New Roman', Times, serif;

View File

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