Compare commits
1 Commits
master
...
develop-ki
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f5bfc7a71 |
52
components.d.ts
vendored
52
components.d.ts
vendored
@@ -16,10 +16,12 @@ declare module 'vue' {
|
|||||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
|
Avatar: typeof import('./src/components/ui/form/Avatar.vue')['default']
|
||||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
Button: typeof import('primevue/button')['default']
|
Button: typeof import('./src/components/ui/form/Button.vue')['default']
|
||||||
|
Card: typeof import('./src/components/ui/form/Card.vue')['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']
|
Checkbox: typeof import('./src/components/ui/form/Checkbox.vue')['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']
|
||||||
@@ -28,31 +30,33 @@ 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('./src/components/ui/form/Dialog.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
FloatLabel: typeof import('primevue/floatlabel')['default']
|
Field: typeof import('./src/components/ui/form/Field.vue')['default']
|
||||||
|
Form: typeof import('./src/components/ui/form/Form.vue')['default']
|
||||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||||
IconField: typeof import('primevue/iconfield')['default']
|
|
||||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||||
InputIcon: typeof import('primevue/inputicon')['default']
|
Input: typeof import('./src/components/ui/form/Input.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']
|
||||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||||
Message: typeof import('primevue/message')['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']
|
ProgressBar: typeof import('./src/components/ui/form/ProgressBar.vue')['default']
|
||||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Select: typeof import('primevue/select')['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']
|
Skeleton: typeof import('./src/components/ui/form/Skeleton.vue')['default']
|
||||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||||
|
Table: typeof import('./src/components/ui/form/Table.vue')['default']
|
||||||
|
Tag: typeof import('./src/components/ui/form/Tag.vue')['default']
|
||||||
|
TanStackForm: typeof import('./src/components/ui/form/TanStackForm.vue')['default']
|
||||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
|
Textarea: typeof import('./src/components/ui/form/Textarea.vue')['default']
|
||||||
|
Toast: typeof import('./src/components/ui/form/Toast.vue')['default']
|
||||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||||
@@ -68,10 +72,12 @@ declare global {
|
|||||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
|
const Avatar: typeof import('./src/components/ui/form/Avatar.vue')['default']
|
||||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
const Button: typeof import('primevue/button')['default']
|
const Button: typeof import('./src/components/ui/form/Button.vue')['default']
|
||||||
|
const Card: typeof import('./src/components/ui/form/Card.vue')['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 Checkbox: typeof import('./src/components/ui/form/Checkbox.vue')['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']
|
||||||
@@ -80,31 +86,33 @@ 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('./src/components/ui/form/Dialog.vue')['default']
|
||||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
const Field: typeof import('./src/components/ui/form/Field.vue')['default']
|
||||||
|
const Form: typeof import('./src/components/ui/form/Form.vue')['default']
|
||||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||||
const IconField: typeof import('primevue/iconfield')['default']
|
|
||||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||||
const InputIcon: typeof import('primevue/inputicon')['default']
|
const Input: typeof import('./src/components/ui/form/Input.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 LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||||
const Message: typeof import('primevue/message')['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 ProgressBar: typeof import('./src/components/ui/form/ProgressBar.vue')['default']
|
||||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const Select: typeof import('primevue/select')['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 Skeleton: typeof import('./src/components/ui/form/Skeleton.vue')['default']
|
||||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||||
|
const Table: typeof import('./src/components/ui/form/Table.vue')['default']
|
||||||
|
const Tag: typeof import('./src/components/ui/form/Tag.vue')['default']
|
||||||
|
const TanStackForm: typeof import('./src/components/ui/form/TanStackForm.vue')['default']
|
||||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
|
const Textarea: typeof import('./src/components/ui/form/Textarea.vue')['default']
|
||||||
|
const Toast: typeof import('./src/components/ui/form/Toast.vue')['default']
|
||||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||||
|
|||||||
34
package.json
34
package.json
@@ -10,37 +10,35 @@
|
|||||||
"tail": "wrangler tail"
|
"tail": "wrangler tail"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.971.0",
|
"@aws-sdk/client-s3": "^3.983.0",
|
||||||
"@aws-sdk/s3-presigned-post": "^3.971.0",
|
"@aws-sdk/s3-presigned-post": "^3.983.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.971.0",
|
"@aws-sdk/s3-request-presigner": "^3.983.0",
|
||||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
|
||||||
"@hiogawa/utils": "^1.7.0",
|
"@hiogawa/utils": "^1.7.0",
|
||||||
"@primeuix/themes": "^2.0.3",
|
"@pinia/colada": "^0.21.2",
|
||||||
"@primevue/forms": "^4.5.4",
|
"@tanstack/vue-form": "^1.28.0",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@unhead/vue": "^2.1.2",
|
"@unhead/vue": "^2.1.2",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.2.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"firebase-admin": "^13.6.0",
|
"hono": "^4.11.7",
|
||||||
"hono": "^4.11.4",
|
|
||||||
"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": "^4.6.4",
|
"vue-router": "^5.0.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.21.0",
|
"@cloudflare/vite-plugin": "^1.23.0",
|
||||||
"@primevue/auto-import-resolver": "^4.5.4",
|
"@types/node": "^25.2.0",
|
||||||
"@types/node": "^25.0.9",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
|
||||||
"unocss": "^66.6.0",
|
"unocss": "^66.6.0",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^31.0.0",
|
"unplugin-vue-components": "^31.0.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-ssr-components": "^0.5.2",
|
"vite-ssr-components": "^0.5.2",
|
||||||
"wrangler": "^4.59.2"
|
"wrangler": "^4.62.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Toast from './ui/form/Toast.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<Toast />
|
||||||
<router-view/>
|
<router-view/>
|
||||||
</template>
|
</template>
|
||||||
37
src/components/ui/form/Avatar.vue
Normal file
37
src/components/ui/form/Avatar.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface AvatarProps {
|
||||||
|
label?: string;
|
||||||
|
shape?: 'circle' | 'square';
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<AvatarProps>(), {
|
||||||
|
shape: 'circle',
|
||||||
|
size: 'medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'w-8 h-8 text-xs',
|
||||||
|
medium: 'w-10 h-10 text-sm',
|
||||||
|
large: 'w-12 h-12 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shapeClasses = {
|
||||||
|
circle: 'rounded-full',
|
||||||
|
square: 'rounded-lg',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center justify-center font-medium bg-gray-200 text-gray-600',
|
||||||
|
sizeClasses[size],
|
||||||
|
shapeClasses[shape],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
{{ label?.charAt(0).toUpperCase() || '?' }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
62
src/components/ui/form/Button.vue
Normal file
62
src/components/ui/form/Button.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface ButtonProps {
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
variant?: 'primary' | 'secondary' | 'outlined' | 'text';
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||||
|
type: 'button',
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'medium',
|
||||||
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary text-white hover:opacity-90',
|
||||||
|
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
|
||||||
|
outlined: 'border border-gray-300 bg-transparent hover:bg-gray-50',
|
||||||
|
text: 'bg-transparent hover:bg-gray-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'px-3 py-1.5 text-xs',
|
||||||
|
medium: 'px-4 py-2 text-sm',
|
||||||
|
large: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:type="type"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary/20',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
loading ? 'cursor-wait' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="loading"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<slot>{{ label }}</slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
16
src/components/ui/form/Card.vue
Normal file
16
src/components/ui/form/Card.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface CardProps {
|
||||||
|
cardClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<CardProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden', cardClass]">
|
||||||
|
<slot name="header" />
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
66
src/components/ui/form/Checkbox.vue
Normal file
66
src/components/ui/form/Checkbox.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
|
interface CheckboxProps {
|
||||||
|
name: string;
|
||||||
|
value?: any;
|
||||||
|
binary?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<CheckboxProps>(), {
|
||||||
|
binary: false,
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formContext = inject<{
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
touched: Record<string, boolean>;
|
||||||
|
handleBlur: (name: string) => void;
|
||||||
|
handleChange: (name: string, value: any) => void;
|
||||||
|
} | null>('form-context', null);
|
||||||
|
|
||||||
|
const error = computed(() => formContext?.errors[props.name]);
|
||||||
|
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
|
||||||
|
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => {
|
||||||
|
const val = formContext?.values[props.name];
|
||||||
|
if (props.binary) return !!val;
|
||||||
|
return val?.includes(props.value);
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
if (props.binary) {
|
||||||
|
formContext?.handleChange(props.name, val);
|
||||||
|
} else {
|
||||||
|
const current = formContext?.values[props.name] || [];
|
||||||
|
if (val) {
|
||||||
|
formContext?.handleChange(props.name, [...current, props.value]);
|
||||||
|
} else {
|
||||||
|
formContext?.handleChange(props.name, current.filter((v: any) => v !== props.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:id="name + '-' + (value ?? 'binary')"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/20',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
isInvalid ? 'border-red-500' : '',
|
||||||
|
]"
|
||||||
|
@blur="formContext?.handleBlur(name)"
|
||||||
|
/>
|
||||||
|
<label v-if="$slots.default" :for="name + '-' + (value ?? 'binary')" class="text-sm text-gray-700">
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
115
src/components/ui/form/Dialog.vue
Normal file
115
src/components/ui/form/Dialog.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
header?: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
closable?: boolean;
|
||||||
|
modal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogProps>(), {
|
||||||
|
closable: true,
|
||||||
|
modal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (props.closable) {
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
if (props.modal) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.visible) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (val) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="dialog">
|
||||||
|
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dialog -->
|
||||||
|
<div
|
||||||
|
class="relative bg-white rounded-xl shadow-xl w-full max-h-[90vh] overflow-auto"
|
||||||
|
:style="style || { width: '28rem' }"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div v-if="header" class="flex items-center justify-between px-6 py-4 border-b">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{{ header }}</h3>
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
@click="handleClose"
|
||||||
|
class="p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 text-gray-500" 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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-6 pt-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-enter-active,
|
||||||
|
.dialog-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-from,
|
||||||
|
.dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-active .relative,
|
||||||
|
.dialog-leave-active .relative {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-from .relative,
|
||||||
|
.dialog-leave-to .relative {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
src/components/ui/form/Field.vue
Normal file
58
src/components/ui/form/Field.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps>();
|
||||||
|
|
||||||
|
const formContext = inject<{
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
touched: Record<string, boolean>;
|
||||||
|
validators: Record<string, ((value: any) => string | undefined)[]>;
|
||||||
|
handleBlur: (name: string) => void;
|
||||||
|
handleChange: (name: string, value: any) => void;
|
||||||
|
} | null>('form-context', null);
|
||||||
|
|
||||||
|
const error = computed(() => props.name ? formContext?.errors[props.name] : undefined);
|
||||||
|
const isInvalid = computed(() => props.name ? formContext?.touched[props.name] && !!error.value : false);
|
||||||
|
const fieldValue = computed(() => props.name ? formContext?.values[props.name] ?? '' : '');
|
||||||
|
|
||||||
|
const onChange = (value: any) => {
|
||||||
|
if (props.name && formContext) {
|
||||||
|
formContext.handleChange(props.name, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
if (props.name && formContext) {
|
||||||
|
formContext.handleBlur(props.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provide values to slot
|
||||||
|
const slotProps = {
|
||||||
|
value: fieldValue,
|
||||||
|
error: error,
|
||||||
|
errorMessage: error,
|
||||||
|
isInvalid,
|
||||||
|
name: props.name,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="field flex flex-col gap-1">
|
||||||
|
<label v-if="label" :for="name" class="text-sm font-medium text-gray-700">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
<div v-if="isInvalid && error" class="text-xs text-red-600 mt-1">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
91
src/components/ui/form/Form.vue
Normal file
91
src/components/ui/form/Form.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { provide, reactive } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
validators?: Record<string, ((value: any) => string | undefined)[]>;
|
||||||
|
formClass?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [values: Record<string, any>];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const errors = reactive<Record<string, string>>({});
|
||||||
|
const touched = reactive<Record<string, boolean>>({});
|
||||||
|
const values = reactive<Record<string, any>>({...props.initialValues});
|
||||||
|
|
||||||
|
// Initialize values
|
||||||
|
if (props.initialValues) {
|
||||||
|
Object.assign(values, props.initialValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateField = (name: string) => {
|
||||||
|
const value = values[name];
|
||||||
|
const fieldValidators = props.validators?.[name] || [];
|
||||||
|
|
||||||
|
for (const validator of fieldValidators) {
|
||||||
|
const error = validator(value);
|
||||||
|
if (error) {
|
||||||
|
errors[name] = error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete errors[name];
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAll = () => {
|
||||||
|
const fieldNames = Object.keys(props.validators || {});
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
for (const name of fieldNames) {
|
||||||
|
if (!validateField(name)) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Mark all fields as touched
|
||||||
|
const fieldNames = Object.keys(props.validators || {});
|
||||||
|
for (const name of fieldNames) {
|
||||||
|
touched[name] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateAll()) {
|
||||||
|
emit('submit', {...values});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (name: string) => {
|
||||||
|
touched[name] = true;
|
||||||
|
validateField(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (name: string, value: any) => {
|
||||||
|
values[name] = value;
|
||||||
|
if (touched[name]) {
|
||||||
|
validateField(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provide form context to child components
|
||||||
|
provide('form-context', {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
validators: props.validators || {},
|
||||||
|
handleBlur,
|
||||||
|
handleChange,
|
||||||
|
validateField,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" :class="[formClass, 'flex flex-col gap-4 w-full']">
|
||||||
|
<slot />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
55
src/components/ui/form/Input.vue
Normal file
55
src/components/ui/form/Input.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface InputProps {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fluid?: boolean;
|
||||||
|
modelValue?: string | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<InputProps>(), {
|
||||||
|
type: 'text',
|
||||||
|
disabled: false,
|
||||||
|
fluid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Handle the v-model binding - support both string and computed ref
|
||||||
|
const inputValue = computed(() => {
|
||||||
|
const val = props.modelValue;
|
||||||
|
// Check if it's a ref/computed
|
||||||
|
if (val && typeof val === 'object' && 'value' in val) {
|
||||||
|
return val.value;
|
||||||
|
}
|
||||||
|
return val ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
emit('update:modelValue', target.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:id="name"
|
||||||
|
:value="inputValue"
|
||||||
|
@input="onInput"
|
||||||
|
:type="type"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors',
|
||||||
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||||
|
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||||
|
fluid ? 'w-full' : '',
|
||||||
|
'border-gray-300',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
30
src/components/ui/form/ProgressBar.vue
Normal file
30
src/components/ui/form/ProgressBar.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value?: number;
|
||||||
|
showValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ProgressBarProps>(), {
|
||||||
|
value: 0,
|
||||||
|
showValue: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
v-if="showValue"
|
||||||
|
class="flex justify-between mb-1"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-700">
|
||||||
|
<slot name="value">{{ Math.round(value) }}%</slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: `${Math.min(100, Math.max(0, value))}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
24
src/components/ui/form/Skeleton.vue
Normal file
24
src/components/ui/form/Skeleton.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface SkeletonProps {
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SkeletonProps>(), {
|
||||||
|
width: '100%',
|
||||||
|
height: '1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="animate-pulse bg-gray-200"
|
||||||
|
:style="{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
35
src/components/ui/form/Table.vue
Normal file
35
src/components/ui/form/Table.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
interface TableProps<T> {
|
||||||
|
value: T[];
|
||||||
|
dataKey: string;
|
||||||
|
selection?: T[];
|
||||||
|
tableStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<TableProps<T>>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selection': [value: T[]];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table :class="['w-full', tableStyle]">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 bg-gray-50">
|
||||||
|
<slot name="header" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(item, index) in value"
|
||||||
|
:key="String((item as any)[dataKey])"
|
||||||
|
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<slot name="body" :data="item" :index="index" />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
src/components/ui/form/Tag.vue
Normal file
31
src/components/ui/form/Tag.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface TagProps {
|
||||||
|
value?: string;
|
||||||
|
severity?: 'success' | 'error' | 'warn' | 'info' | 'secondary';
|
||||||
|
rounded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TagProps>(), {
|
||||||
|
rounded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const severityClasses = {
|
||||||
|
success: 'bg-green-100 text-green-800',
|
||||||
|
error: 'bg-red-100 text-red-800',
|
||||||
|
warn: 'bg-yellow-100 text-yellow-800',
|
||||||
|
info: 'bg-blue-100 text-blue-800',
|
||||||
|
secondary: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
rounded ? 'rounded-full' : 'rounded',
|
||||||
|
severity ? severityClasses[severity] : 'bg-gray-100 text-gray-800',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot>{{ value }}</slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
34
src/components/ui/form/TanStackForm.vue
Normal file
34
src/components/ui/form/TanStackForm.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { useForm } from '@tanstack/vue-form';
|
||||||
|
import { provide } from 'vue';
|
||||||
|
|
||||||
|
interface FormProps {
|
||||||
|
initialValues?: T;
|
||||||
|
onSubmit?: (values: T) => void | Promise<void>;
|
||||||
|
validators?: Record<string, (value: any) => string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FormProps>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: props.initialValues,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
await props.onSubmit?.(values.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide form context to child components
|
||||||
|
provide('tanstack-form', form);
|
||||||
|
provide('tanstack-form-validators', props.validators || {});
|
||||||
|
|
||||||
|
const handleSubmit = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit="handleSubmit" class="flex flex-col gap-4 w-full">
|
||||||
|
<slot :form="form" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
50
src/components/ui/form/Textarea.vue
Normal file
50
src/components/ui/form/Textarea.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
|
interface TextareaProps {
|
||||||
|
name: string;
|
||||||
|
rows?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fluid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||||
|
rows: 3,
|
||||||
|
disabled: false,
|
||||||
|
fluid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formContext = inject<{
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
touched: Record<string, boolean>;
|
||||||
|
handleBlur: (name: string) => void;
|
||||||
|
handleChange: (name: string, value: any) => void;
|
||||||
|
} | null>('form-context', null);
|
||||||
|
|
||||||
|
const error = computed(() => formContext?.errors[props.name]);
|
||||||
|
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => formContext?.values[props.name] ?? '',
|
||||||
|
set: (val) => formContext?.handleChange(props.name, val),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
:id="name"
|
||||||
|
v-model="modelValue"
|
||||||
|
:rows="rows"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors resize-none',
|
||||||
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||||
|
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||||
|
fluid ? 'w-full' : '',
|
||||||
|
isInvalid ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300',
|
||||||
|
]"
|
||||||
|
@blur="formContext?.handleBlur(name)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
104
src/components/ui/form/Toast.vue
Normal file
104
src/components/ui/form/Toast.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { provide, ref } from 'vue';
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
severity: 'success' | 'error' | 'warn' | 'info';
|
||||||
|
summary: string;
|
||||||
|
detail?: string;
|
||||||
|
life?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const addToast = (toast: Omit<ToastItem, 'id'>) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const newToast = { ...toast, id };
|
||||||
|
toasts.value.push(newToast);
|
||||||
|
|
||||||
|
const life = toast.life || 5000;
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, life);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id: string) => {
|
||||||
|
const index = toasts.value.findIndex(t => t.id === id);
|
||||||
|
if (index > -1) {
|
||||||
|
toasts.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
add: addToast,
|
||||||
|
remove: removeToast,
|
||||||
|
};
|
||||||
|
|
||||||
|
provide('toast', toast);
|
||||||
|
|
||||||
|
const severityClasses = {
|
||||||
|
success: 'bg-green-50 text-green-800 border-green-200',
|
||||||
|
error: 'bg-red-50 text-red-800 border-red-200',
|
||||||
|
warn: 'bg-yellow-50 text-yellow-800 border-yellow-200',
|
||||||
|
info: 'bg-blue-50 text-blue-800 border-blue-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityIcons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
warn: '⚠',
|
||||||
|
info: 'ℹ',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
:class="[
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
|
||||||
|
severityClasses[toast.severity],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="text-lg">{{ severityIcons[toast.severity] }}</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium">{{ toast.summary }}</p>
|
||||||
|
<p v-if="toast.detail" class="text-sm opacity-90 mt-1">{{ toast.detail }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="removeToast(toast.id)"
|
||||||
|
class="p-1 hover:bg-black/5 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-move {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/components/ui/form/index.ts
Normal file
15
src/components/ui/form/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export { default as Avatar } from './Avatar.vue';
|
||||||
|
export { default as Button } from './Button.vue';
|
||||||
|
export { default as Card } from './Card.vue';
|
||||||
|
export { default as Checkbox } from './Checkbox.vue';
|
||||||
|
export { default as Dialog } from './Dialog.vue';
|
||||||
|
export { default as Field } from './Field.vue';
|
||||||
|
export { default as Form } from './Form.vue';
|
||||||
|
export { default as Input } from './Input.vue';
|
||||||
|
export { default as ProgressBar } from './ProgressBar.vue';
|
||||||
|
export { default as Skeleton } from './Skeleton.vue';
|
||||||
|
export { default as Table } from './Table.vue';
|
||||||
|
export { default as Tag } from './Tag.vue';
|
||||||
|
export { default as Textarea } from './Textarea.vue';
|
||||||
|
export { default as Toast } from './Toast.vue';
|
||||||
|
|
||||||
@@ -6,14 +6,10 @@ import { streamText } from 'hono/streaming';
|
|||||||
import isMobile from 'is-mobile';
|
import isMobile from 'is-mobile';
|
||||||
import { renderToWebStream } from 'vue/server-renderer';
|
import { renderToWebStream } from 'vue/server-renderer';
|
||||||
import { buildBootstrapScript } from './lib/manifest';
|
import { buildBootstrapScript } from './lib/manifest';
|
||||||
import { styleTags } from './lib/primePassthrough';
|
import { createTextTransformStreamClass } from './lib/replateStreamText';
|
||||||
import { createApp } from './main';
|
import { createApp } from './main';
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
// @ts-ignore
|
|
||||||
import Base from '@primevue/core/base';
|
|
||||||
import { createTextTransformStreamClass } from './lib/replateStreamText';
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
|
||||||
// app.use(renderer)
|
// app.use(renderer)
|
||||||
app.use('*', contextStorage());
|
app.use('*', contextStorage());
|
||||||
app.use(cors(), async (c, next) => {
|
app.use(cors(), async (c, next) => {
|
||||||
@@ -60,12 +56,8 @@ app.get("*", async (c) => {
|
|||||||
app.provide("honoContext", c);
|
app.provide("honoContext", c);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
auth.$reset();
|
auth.$reset();
|
||||||
// auth.initialized = false;
|
|
||||||
await auth.init();
|
|
||||||
await router.push(url.pathname);
|
await router.push(url.pathname);
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
let 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");
|
||||||
@@ -81,10 +73,6 @@ app.get("*", async (c) => {
|
|||||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||||
await stream.write(buildBootstrapScript());
|
await stream.write(buildBootstrapScript());
|
||||||
if (usedStyles.size > 0) {
|
|
||||||
defaultNames.forEach(name => usedStyles.add(name));
|
|
||||||
}
|
|
||||||
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
|
|
||||||
await stream.write(`</head><body class='${bodyClass}'>`);
|
await stream.write(`</head><body class='${bodyClass}'>`);
|
||||||
await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
||||||
delete ctx.teleports
|
delete ctx.teleports
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
77
src/lib/swr/cache/adapters/localStorage.ts
vendored
77
src/lib/swr/cache/adapters/localStorage.ts
vendored
@@ -1,77 +0,0 @@
|
|||||||
import SWRVCache, { type ICacheItem } from '..'
|
|
||||||
import type { IKey } from '../../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LocalStorage cache adapter for swrv data cache.
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
|
|
||||||
*/
|
|
||||||
export default class LocalStorageCache extends SWRVCache<any> {
|
|
||||||
private STORAGE_KEY
|
|
||||||
|
|
||||||
constructor (key = 'swrv', ttl = 0) {
|
|
||||||
super(ttl)
|
|
||||||
this.STORAGE_KEY = key
|
|
||||||
}
|
|
||||||
|
|
||||||
private encode (storage: any) { return JSON.stringify(storage) }
|
|
||||||
private decode (storage: any) { return JSON.parse(storage) }
|
|
||||||
|
|
||||||
get (k: IKey): ICacheItem<IKey> {
|
|
||||||
const item = localStorage.getItem(this.STORAGE_KEY)
|
|
||||||
if (item) {
|
|
||||||
const _key = this.serializeKey(k)
|
|
||||||
const itemParsed: ICacheItem<any> = JSON.parse(item)[_key]
|
|
||||||
|
|
||||||
if (itemParsed?.expiresAt === null) {
|
|
||||||
itemParsed.expiresAt = Infinity // localStorage sets Infinity to 'null'
|
|
||||||
}
|
|
||||||
|
|
||||||
return itemParsed
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined as any
|
|
||||||
}
|
|
||||||
|
|
||||||
set (k: string, v: any, ttl: number) {
|
|
||||||
let payload = {}
|
|
||||||
const _key = this.serializeKey(k)
|
|
||||||
const timeToLive = ttl || this.ttl
|
|
||||||
const storage = localStorage.getItem(this.STORAGE_KEY)
|
|
||||||
const now = Date.now()
|
|
||||||
const item = {
|
|
||||||
data: v,
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: timeToLive ? now + timeToLive : Infinity
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storage) {
|
|
||||||
payload = this.decode(storage)
|
|
||||||
(payload as any)[_key] = item
|
|
||||||
} else {
|
|
||||||
payload = { [_key]: item }
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dispatchExpire(timeToLive, item, _key)
|
|
||||||
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchExpire (ttl: number, item: any, serializedKey: string) {
|
|
||||||
ttl && setTimeout(() => {
|
|
||||||
const current = Date.now()
|
|
||||||
const hasExpired = current >= item.expiresAt
|
|
||||||
if (hasExpired) this.delete(serializedKey)
|
|
||||||
}, ttl)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete (serializedKey: string) {
|
|
||||||
const storage = localStorage.getItem(this.STORAGE_KEY)
|
|
||||||
let payload = {} as Record<string, any>
|
|
||||||
|
|
||||||
if (storage) {
|
|
||||||
payload = this.decode(storage)
|
|
||||||
delete payload[serializedKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
72
src/lib/swr/cache/index.ts
vendored
72
src/lib/swr/cache/index.ts
vendored
@@ -1,72 +0,0 @@
|
|||||||
import type { IKey } from '../types'
|
|
||||||
import hash from '../lib/hash'
|
|
||||||
export interface ICacheItem<Data> {
|
|
||||||
data: Data,
|
|
||||||
createdAt: number,
|
|
||||||
expiresAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeKeyDefault (key: IKey): string {
|
|
||||||
if (typeof key === 'function') {
|
|
||||||
try {
|
|
||||||
key = key()
|
|
||||||
} catch (err) {
|
|
||||||
// dependencies not ready
|
|
||||||
key = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(key)) {
|
|
||||||
key = hash(key)
|
|
||||||
} else {
|
|
||||||
// convert null to ''
|
|
||||||
key = String(key || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class SWRVCache<CacheData> {
|
|
||||||
protected ttl: number
|
|
||||||
private items?: Map<string, ICacheItem<CacheData>>
|
|
||||||
|
|
||||||
constructor (ttl = 0) {
|
|
||||||
this.items = new Map()
|
|
||||||
this.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
serializeKey (key: IKey): string {
|
|
||||||
return serializeKeyDefault(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
get (k: string): ICacheItem<CacheData> {
|
|
||||||
const _key = this.serializeKey(k)
|
|
||||||
return this.items!.get(_key)!
|
|
||||||
}
|
|
||||||
|
|
||||||
set (k: string, v: any, ttl: number) {
|
|
||||||
const _key = this.serializeKey(k)
|
|
||||||
const timeToLive = ttl || this.ttl
|
|
||||||
const now = Date.now()
|
|
||||||
const item = {
|
|
||||||
data: v,
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: timeToLive ? now + timeToLive : Infinity
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dispatchExpire(timeToLive, item, _key)
|
|
||||||
this.items!.set(_key, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchExpire (ttl: number, item: any, serializedKey: string) {
|
|
||||||
ttl && setTimeout(() => {
|
|
||||||
const current = Date.now()
|
|
||||||
const hasExpired = current >= item.expiresAt
|
|
||||||
if (hasExpired) this.delete(serializedKey)
|
|
||||||
}, ttl)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete (serializedKey: string) {
|
|
||||||
this.items!.delete(serializedKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import SWRVCache from './cache'
|
|
||||||
import useSWRV, { mutate } from './use-swrv'
|
|
||||||
|
|
||||||
export {
|
|
||||||
type IConfig
|
|
||||||
} from './types'
|
|
||||||
export { mutate, SWRVCache }
|
|
||||||
export default useSWRV
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// From https://github.com/vercel/swr/blob/master/src/libs/hash.ts
|
|
||||||
// use WeakMap to store the object->key mapping
|
|
||||||
// so the objects can be garbage collected.
|
|
||||||
// WeakMap uses a hashtable under the hood, so the lookup
|
|
||||||
// complexity is almost O(1).
|
|
||||||
const table = new WeakMap()
|
|
||||||
|
|
||||||
// counter of the key
|
|
||||||
let counter = 0
|
|
||||||
|
|
||||||
// hashes an array of objects and returns a string
|
|
||||||
export default function hash (args: any[]): string {
|
|
||||||
if (!args.length) return ''
|
|
||||||
let key = 'arg'
|
|
||||||
for (let i = 0; i < args.length; ++i) {
|
|
||||||
let _hash
|
|
||||||
if (
|
|
||||||
args[i] === null ||
|
|
||||||
(typeof args[i] !== 'object' && typeof args[i] !== 'function')
|
|
||||||
) {
|
|
||||||
// need to consider the case that args[i] is a string:
|
|
||||||
// args[i] _hash
|
|
||||||
// "undefined" -> '"undefined"'
|
|
||||||
// undefined -> 'undefined'
|
|
||||||
// 123 -> '123'
|
|
||||||
// null -> 'null'
|
|
||||||
// "null" -> '"null"'
|
|
||||||
if (typeof args[i] === 'string') {
|
|
||||||
_hash = '"' + args[i] + '"'
|
|
||||||
} else {
|
|
||||||
_hash = String(args[i])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!table.has(args[i])) {
|
|
||||||
_hash = counter
|
|
||||||
table.set(args[i], counter++)
|
|
||||||
} else {
|
|
||||||
_hash = table.get(args[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key += '@' + _hash
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
function isOnline (): boolean {
|
|
||||||
if (typeof navigator.onLine !== 'undefined') {
|
|
||||||
return navigator.onLine
|
|
||||||
}
|
|
||||||
// always assume it's online
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDocumentVisible (): boolean {
|
|
||||||
if (
|
|
||||||
typeof document !== 'undefined' &&
|
|
||||||
typeof document.visibilityState !== 'undefined'
|
|
||||||
) {
|
|
||||||
return document.visibilityState !== 'hidden'
|
|
||||||
}
|
|
||||||
// always assume it's visible
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetcher = (url: string | Request) => fetch(url).then(res => res.json())
|
|
||||||
|
|
||||||
export default {
|
|
||||||
isOnline,
|
|
||||||
isDocumentVisible,
|
|
||||||
fetcher
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { Ref, WatchSource } from 'vue'
|
|
||||||
import SWRVCache from './cache'
|
|
||||||
import LocalStorageCache from './cache/adapters/localStorage'
|
|
||||||
|
|
||||||
export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
|
|
||||||
|
|
||||||
export interface IConfig<
|
|
||||||
Data = any,
|
|
||||||
Fn extends fetcherFn<Data> = fetcherFn<Data>
|
|
||||||
> {
|
|
||||||
refreshInterval?: number
|
|
||||||
cache?: LocalStorageCache | SWRVCache<any>
|
|
||||||
dedupingInterval?: number
|
|
||||||
ttl?: number
|
|
||||||
serverTTL?: number
|
|
||||||
revalidateOnFocus?: boolean
|
|
||||||
revalidateDebounce?: number
|
|
||||||
shouldRetryOnError?: boolean
|
|
||||||
errorRetryInterval?: number
|
|
||||||
errorRetryCount?: number
|
|
||||||
fetcher?: Fn,
|
|
||||||
isOnline?: () => boolean
|
|
||||||
isDocumentVisible?: () => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface revalidateOptions {
|
|
||||||
shouldRetryOnError?: boolean,
|
|
||||||
errorRetryCount?: number,
|
|
||||||
forceRevalidate?: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IResponse<Data = any, Error = any> {
|
|
||||||
data: Ref<Data | undefined>
|
|
||||||
error: Ref<Error | undefined>
|
|
||||||
isValidating: Ref<boolean>
|
|
||||||
isLoading: Ref<boolean>
|
|
||||||
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type keyType = string | any[] | null | undefined
|
|
||||||
|
|
||||||
export type IKey = keyType | WatchSource<keyType>
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
/** ____
|
|
||||||
*--------------/ \.------------------/
|
|
||||||
* / swrv \. / //
|
|
||||||
* / / /\. / //
|
|
||||||
* / _____/ / \. /
|
|
||||||
* / / ____/ . \. /
|
|
||||||
* / \ \_____ \. /
|
|
||||||
* / . \_____ \ \ / //
|
|
||||||
* \ _____/ / ./ / //
|
|
||||||
* \ / _____/ ./ /
|
|
||||||
* \ / / . ./ /
|
|
||||||
* \ / / ./ /
|
|
||||||
* . \/ ./ / //
|
|
||||||
* \ ./ / //
|
|
||||||
* \.. / /
|
|
||||||
* . ||| /
|
|
||||||
* ||| /
|
|
||||||
* . ||| / //
|
|
||||||
* ||| / //
|
|
||||||
* ||| /
|
|
||||||
*/
|
|
||||||
import { tinyassert } from "@hiogawa/utils";
|
|
||||||
import {
|
|
||||||
getCurrentInstance,
|
|
||||||
inject,
|
|
||||||
isReadonly,
|
|
||||||
isRef,
|
|
||||||
// isRef,
|
|
||||||
onMounted,
|
|
||||||
onServerPrefetch,
|
|
||||||
onUnmounted,
|
|
||||||
reactive,
|
|
||||||
ref,
|
|
||||||
toRefs,
|
|
||||||
useSSRContext,
|
|
||||||
watch,
|
|
||||||
type FunctionPlugin
|
|
||||||
} from 'vue';
|
|
||||||
import SWRVCache from './cache';
|
|
||||||
import webPreset from './lib/web-preset';
|
|
||||||
import type { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types';
|
|
||||||
|
|
||||||
type StateRef<Data, Error> = {
|
|
||||||
data: Data, error: Error, isValidating: boolean, isLoading: boolean, revalidate: Function, key: any
|
|
||||||
};
|
|
||||||
|
|
||||||
const DATA_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
|
|
||||||
const REF_CACHE = new SWRVCache<StateRef<any, any>[]>()
|
|
||||||
const PROMISES_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
|
|
||||||
|
|
||||||
const defaultConfig: IConfig = {
|
|
||||||
cache: DATA_CACHE,
|
|
||||||
refreshInterval: 0,
|
|
||||||
ttl: 0,
|
|
||||||
serverTTL: 1000,
|
|
||||||
dedupingInterval: 2000,
|
|
||||||
revalidateOnFocus: true,
|
|
||||||
revalidateDebounce: 0,
|
|
||||||
shouldRetryOnError: true,
|
|
||||||
errorRetryInterval: 5000,
|
|
||||||
errorRetryCount: 5,
|
|
||||||
fetcher: webPreset.fetcher,
|
|
||||||
isOnline: webPreset.isOnline,
|
|
||||||
isDocumentVisible: webPreset.isDocumentVisible
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache the refs for later revalidation
|
|
||||||
*/
|
|
||||||
function setRefCache(key: string, theRef: StateRef<any, any>, ttl: number) {
|
|
||||||
const refCacheItem = REF_CACHE.get(key)
|
|
||||||
if (refCacheItem) {
|
|
||||||
refCacheItem.data.push(theRef)
|
|
||||||
} else {
|
|
||||||
// #51 ensures ref cache does not evict too soon
|
|
||||||
const gracePeriod = 5000
|
|
||||||
REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onErrorRetry(revalidate: (any: any, opts: revalidateOptions) => void, errorRetryCount: number, config: IConfig): void {
|
|
||||||
if (!(config as any).isDocumentVisible()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.errorRetryCount !== undefined && errorRetryCount > config.errorRetryCount) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Math.min(errorRetryCount || 0, (config as any).errorRetryCount)
|
|
||||||
const timeout = count * (config as any).errorRetryInterval
|
|
||||||
setTimeout(() => {
|
|
||||||
revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true })
|
|
||||||
}, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main mutation function for receiving data from promises to change state and
|
|
||||||
* set data cache
|
|
||||||
*/
|
|
||||||
const mutate = async <Data>(key: string, res: Promise<Data> | Data, cache = DATA_CACHE, ttl = defaultConfig.ttl) => {
|
|
||||||
let data, error, isValidating
|
|
||||||
|
|
||||||
if (isPromise(res)) {
|
|
||||||
try {
|
|
||||||
data = await res
|
|
||||||
} catch (err) {
|
|
||||||
error = err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = res
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-const
|
|
||||||
isValidating = false
|
|
||||||
|
|
||||||
const newData = { data, error, isValidating }
|
|
||||||
if (typeof data !== 'undefined') {
|
|
||||||
try {
|
|
||||||
cache.set(key, newData, Number(ttl))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('swrv(mutate): failed to set cache', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revalidate all swrv instances with new data
|
|
||||||
*/
|
|
||||||
const stateRef = REF_CACHE.get(key)
|
|
||||||
if (stateRef && stateRef.data.length) {
|
|
||||||
// This filter fixes #24 race conditions to only update ref data of current
|
|
||||||
// key, while data cache will continue to be updated if revalidation is
|
|
||||||
// fired
|
|
||||||
let refs = stateRef.data.filter(r => r.key === key)
|
|
||||||
|
|
||||||
refs.forEach((r, idx) => {
|
|
||||||
if (typeof newData.data !== 'undefined') {
|
|
||||||
r.data = newData.data
|
|
||||||
}
|
|
||||||
r.error = newData.error
|
|
||||||
r.isValidating = newData.isValidating
|
|
||||||
r.isLoading = newData.isValidating
|
|
||||||
|
|
||||||
const isLast = idx === refs.length - 1
|
|
||||||
if (!isLast) {
|
|
||||||
// Clean up refs that belonged to old keys
|
|
||||||
delete refs[idx]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
refs = refs.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newData
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
|
|
||||||
function useSWRV<Data = any, Error = any>(
|
|
||||||
key: IKey
|
|
||||||
): IResponse<Data, Error>
|
|
||||||
function useSWRV<Data = any, Error = any>(
|
|
||||||
key: IKey,
|
|
||||||
fn: fetcherFn<Data> | undefined | null,
|
|
||||||
config?: IConfig
|
|
||||||
): IResponse<Data, Error>
|
|
||||||
function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error> {
|
|
||||||
const injectedConfig = inject<Partial<IConfig> | null>('swrv-config', null)
|
|
||||||
tinyassert(injectedConfig, 'Injected swrv-config must be an object')
|
|
||||||
let key: IKey
|
|
||||||
let fn: fetcherFn<Data> | undefined | null
|
|
||||||
let config: IConfig = { ...defaultConfig, ...injectedConfig }
|
|
||||||
let unmounted = false
|
|
||||||
let isHydrated = false
|
|
||||||
|
|
||||||
const instance = getCurrentInstance() as any
|
|
||||||
const vm = instance?.proxy || instance // https://github.com/vuejs/composition-api/pull/520
|
|
||||||
if (!vm) {
|
|
||||||
console.error('Could not get current instance, check to make sure that `useSwrv` is declared in the top level of the setup function.')
|
|
||||||
throw new Error('Could not get current instance')
|
|
||||||
}
|
|
||||||
|
|
||||||
const IS_SERVER = typeof window === 'undefined' || false
|
|
||||||
// #region ssr
|
|
||||||
const isSsrHydration = Boolean(
|
|
||||||
!IS_SERVER &&
|
|
||||||
window !== undefined && (window as any).window.swrv)
|
|
||||||
// #endregion
|
|
||||||
if (args.length >= 1) {
|
|
||||||
key = args[0]
|
|
||||||
}
|
|
||||||
if (args.length >= 2) {
|
|
||||||
fn = args[1]
|
|
||||||
}
|
|
||||||
if (args.length > 2) {
|
|
||||||
config = {
|
|
||||||
...config,
|
|
||||||
...args[2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ttl = IS_SERVER ? config.serverTTL : config.ttl
|
|
||||||
const keyRef = typeof key === 'function' ? (key as any) : ref(key)
|
|
||||||
|
|
||||||
if (typeof fn === 'undefined') {
|
|
||||||
// use the global fetcher
|
|
||||||
fn = config.fetcher
|
|
||||||
}
|
|
||||||
|
|
||||||
let stateRef: StateRef<Data, Error> | null = null
|
|
||||||
// #region ssr
|
|
||||||
if (isSsrHydration) {
|
|
||||||
// component was ssrHydrated, so make the ssr reactive as the initial data
|
|
||||||
|
|
||||||
const swrvState = (window as any).window.swrv || []
|
|
||||||
const swrvKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
|
|
||||||
if (swrvKey !== undefined && swrvKey !== null) {
|
|
||||||
const nodeState = swrvState[swrvKey] || []
|
|
||||||
const instanceState = nodeState[nanoHex(isRef(keyRef) ? keyRef.value : keyRef())]
|
|
||||||
|
|
||||||
if (instanceState) {
|
|
||||||
stateRef = reactive(instanceState)
|
|
||||||
isHydrated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
if (!stateRef) {
|
|
||||||
stateRef = reactive({
|
|
||||||
data: undefined,
|
|
||||||
error: undefined,
|
|
||||||
isValidating: true,
|
|
||||||
isLoading: true,
|
|
||||||
key: null
|
|
||||||
}) as StateRef<Data, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revalidate the cache, mutate data
|
|
||||||
*/
|
|
||||||
const revalidate = async (data?: fetcherFn<Data>, opts?: revalidateOptions) => {
|
|
||||||
const isFirstFetch = stateRef.data === undefined
|
|
||||||
const keyVal = keyRef.value
|
|
||||||
if (!keyVal) { return }
|
|
||||||
|
|
||||||
const cacheItem = config.cache!.get(keyVal)
|
|
||||||
const newData = cacheItem && cacheItem.data
|
|
||||||
|
|
||||||
stateRef.isValidating = true
|
|
||||||
stateRef.isLoading = !newData
|
|
||||||
if (newData) {
|
|
||||||
stateRef.data = newData.data
|
|
||||||
stateRef.error = newData.error
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetcher = data || fn
|
|
||||||
if (
|
|
||||||
!fetcher ||
|
|
||||||
(!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) ||
|
|
||||||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
|
|
||||||
) {
|
|
||||||
stateRef.isValidating = false
|
|
||||||
stateRef.isLoading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedupe items that were created in the last interval #76
|
|
||||||
if (cacheItem) {
|
|
||||||
const shouldRevalidate = Boolean(
|
|
||||||
((Date.now() - cacheItem.createdAt) >= (config as any).dedupingInterval) || opts?.forceRevalidate
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!shouldRevalidate) {
|
|
||||||
stateRef.isValidating = false
|
|
||||||
stateRef.isLoading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trigger = async () => {
|
|
||||||
const promiseFromCache = PROMISES_CACHE.get(keyVal)
|
|
||||||
if (!promiseFromCache) {
|
|
||||||
const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal]
|
|
||||||
const newPromise = fetcher(...fetcherArgs)
|
|
||||||
PROMISES_CACHE.set(keyVal, newPromise, (config as any).dedupingInterval)
|
|
||||||
await mutate(keyVal, newPromise, (config as any).cache, ttl)
|
|
||||||
} else {
|
|
||||||
await mutate(keyVal, promiseFromCache.data, (config as any).cache, ttl)
|
|
||||||
}
|
|
||||||
stateRef.isValidating = false
|
|
||||||
stateRef.isLoading = false
|
|
||||||
PROMISES_CACHE.delete(keyVal)
|
|
||||||
if (stateRef.error !== undefined) {
|
|
||||||
const shouldRetryOnError = !unmounted && config.shouldRetryOnError && (opts ? opts.shouldRetryOnError : true)
|
|
||||||
if (shouldRetryOnError) {
|
|
||||||
onErrorRetry(revalidate, opts ? Number(opts.errorRetryCount) : 1, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newData && config.revalidateDebounce) {
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (!unmounted) {
|
|
||||||
await trigger()
|
|
||||||
}
|
|
||||||
}, config.revalidateDebounce)
|
|
||||||
} else {
|
|
||||||
await trigger()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const revalidateCall = async () => revalidate(null as any, { shouldRetryOnError: false })
|
|
||||||
let timer: any = null
|
|
||||||
/**
|
|
||||||
* Setup polling
|
|
||||||
*/
|
|
||||||
onMounted(() => {
|
|
||||||
const tick = async () => {
|
|
||||||
// component might un-mount during revalidate, so do not set a new timeout
|
|
||||||
// if this is the case, but continue to revalidate since promises can't
|
|
||||||
// be cancelled and new hook instances might rely on promise/data cache or
|
|
||||||
// from pre-fetch
|
|
||||||
if (!stateRef.error && (config as any).isOnline()) {
|
|
||||||
// if API request errored, we stop polling in this round
|
|
||||||
// and let the error retry function handle it
|
|
||||||
await revalidate()
|
|
||||||
} else {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.refreshInterval && !unmounted) {
|
|
||||||
timer = setTimeout(tick, config.refreshInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.refreshInterval) {
|
|
||||||
timer = setTimeout(tick, config.refreshInterval)
|
|
||||||
}
|
|
||||||
if (config.revalidateOnFocus) {
|
|
||||||
document.addEventListener('visibilitychange', revalidateCall, false)
|
|
||||||
window.addEventListener('focus', revalidateCall, false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Teardown
|
|
||||||
*/
|
|
||||||
onUnmounted(() => {
|
|
||||||
unmounted = true
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
if (config.revalidateOnFocus) {
|
|
||||||
document.removeEventListener('visibilitychange', revalidateCall, false)
|
|
||||||
window.removeEventListener('focus', revalidateCall, false)
|
|
||||||
}
|
|
||||||
const refCacheItem = REF_CACHE.get(keyRef.value)
|
|
||||||
if (refCacheItem) {
|
|
||||||
refCacheItem.data = refCacheItem.data.filter((ref) => ref !== stateRef)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// #region ssr
|
|
||||||
if (IS_SERVER) {
|
|
||||||
const ssrContext = useSSRContext()
|
|
||||||
// make sure srwv exists in ssrContext
|
|
||||||
let swrvRes: Record<string, any> = {}
|
|
||||||
if (ssrContext) {
|
|
||||||
swrvRes = ssrContext.swrv = ssrContext.swrv || swrvRes
|
|
||||||
}
|
|
||||||
|
|
||||||
const ssrKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
|
|
||||||
// if (!vm.$vnode || (vm.$node && !vm.$node.data)) {
|
|
||||||
// vm.$vnode = {
|
|
||||||
// data: { attrs: { 'data-swrv-key': ssrKey } }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {})
|
|
||||||
// attrs['data-swrv-key'] = ssrKey
|
|
||||||
// // Nuxt compatibility
|
|
||||||
// if (vm.$ssrContext && vm.$ssrContext.nuxt) {
|
|
||||||
// vm.$ssrContext.nuxt.swrv = swrvRes
|
|
||||||
// }
|
|
||||||
if (ssrContext) {
|
|
||||||
ssrContext.swrv = swrvRes
|
|
||||||
}
|
|
||||||
onServerPrefetch(async () => {
|
|
||||||
await revalidate()
|
|
||||||
if (!swrvRes[ssrKey]) swrvRes[ssrKey] = {}
|
|
||||||
|
|
||||||
swrvRes[ssrKey][nanoHex(keyRef.value)] = {
|
|
||||||
data: stateRef.data,
|
|
||||||
error: stateRef.error,
|
|
||||||
isValidating: stateRef.isValidating
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revalidate when key dependencies change
|
|
||||||
*/
|
|
||||||
try {
|
|
||||||
watch(keyRef, (val) => {
|
|
||||||
if (!isReadonly(keyRef)) {
|
|
||||||
keyRef.value = val
|
|
||||||
}
|
|
||||||
stateRef.key = val
|
|
||||||
stateRef.isValidating = Boolean(val)
|
|
||||||
setRefCache(keyRef.value, stateRef, Number(ttl))
|
|
||||||
|
|
||||||
if (!IS_SERVER && !isHydrated && keyRef.value) {
|
|
||||||
revalidate()
|
|
||||||
}
|
|
||||||
isHydrated = false
|
|
||||||
}, {
|
|
||||||
immediate: true
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
const res: IResponse = {
|
|
||||||
...toRefs(stateRef),
|
|
||||||
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => revalidate(data, {
|
|
||||||
...opts,
|
|
||||||
forceRevalidate: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPromise<T>(p: any): p is Promise<T> {
|
|
||||||
return p !== null && typeof p === 'object' && typeof p.then === 'function'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* string to hex 8 chars
|
|
||||||
* @param name string
|
|
||||||
* @returns string
|
|
||||||
*/
|
|
||||||
function nanoHex(name: string): string {
|
|
||||||
try {
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
const chr = name.charCodeAt(i)
|
|
||||||
hash = ((hash << 5) - hash) + chr
|
|
||||||
hash |= 0 // Convert to 32bit integer
|
|
||||||
}
|
|
||||||
let hex = (hash >>> 0).toString(16)
|
|
||||||
while (hex.length < 8) {
|
|
||||||
hex = '0' + hex
|
|
||||||
}
|
|
||||||
return hex
|
|
||||||
} catch {
|
|
||||||
console.error("err name: ", name)
|
|
||||||
return '0000'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export const vueSWR = (swrvConfig: Partial<IConfig> = defaultConfig): FunctionPlugin => (app) => {
|
|
||||||
app.config.globalProperties.$swrv = useSWRV
|
|
||||||
// app.provide('swrv', useSWRV)
|
|
||||||
app.provide('swrv-config', swrvConfig)
|
|
||||||
}
|
|
||||||
export { mutate };
|
|
||||||
export default useSWRV
|
|
||||||
25
src/main.ts
25
src/main.ts
@@ -1,16 +1,10 @@
|
|||||||
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 { 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 { vueSWR } from './lib/swr/use-swrv';
|
|
||||||
import createAppRouter from './routes';
|
import createAppRouter from './routes';
|
||||||
import PrimeVue from 'primevue/config';
|
|
||||||
import Aura from '@primeuix/themes/aura';
|
|
||||||
import { createPinia } from "pinia";
|
|
||||||
import { useAuthStore } from './stores/auth';
|
|
||||||
import ToastService from 'primevue/toastservice';
|
|
||||||
import Tooltip from 'primevue/tooltip';
|
|
||||||
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();
|
||||||
@@ -18,27 +12,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: Aura,
|
|
||||||
options: {
|
|
||||||
darkModeSelector: '.my-app-dark',
|
|
||||||
cssLayer: false,
|
|
||||||
// cssLayer: {
|
|
||||||
// name: 'primevue',
|
|
||||||
// order: 'theme, base, primevue'
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
app.use(ToastService);
|
|
||||||
app.directive('nh', {
|
app.directive('nh', {
|
||||||
created(el) {
|
created(el) {
|
||||||
el.__v_skip = true;
|
el.__v_skip = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.directive("tooltip", Tooltip)
|
|
||||||
if (!import.meta.env.SSR) {
|
if (!import.meta.env.SSR) {
|
||||||
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
||||||
(window as any)[key] = value;
|
(window as any)[key] = value;
|
||||||
@@ -48,7 +26,6 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(vueSWR({ revalidateOnFocus: false }));
|
|
||||||
const router = createAppRouter();
|
const router = createAppRouter();
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { client, type ModelVideo } from '@/api/client';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||||
import { client, type ModelVideo } from '@/api/client';
|
import { Skeleton } from '@/components/ui/form';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -156,23 +156,23 @@ onMounted(() => {
|
|||||||
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
|
<div v-for="i in 4" :key="i" class="bg-white 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>
|
<Skeleton width="5rem" height="1rem" class="mb-2 rounded" />
|
||||||
<Skeleton width="8rem" height="2rem"></Skeleton>
|
<Skeleton width="8rem" height="2rem" class="rounded" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton shape="circle" size="3rem"></Skeleton>
|
<Skeleton width="3rem" height="3rem" class="rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton width="4rem" height="1rem"></Skeleton>
|
<Skeleton width="4rem" height="1rem" class="rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions Skeleton -->
|
<!-- Quick Actions Skeleton -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
|
<Skeleton width="10rem" height="1.5rem" class="mb-4 rounded" />
|
||||||
<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>
|
<Skeleton width="3rem" height="3rem" class="mb-4 rounded-full" />
|
||||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
<Skeleton width="8rem" height="1.25rem" class="mb-2 rounded" />
|
||||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
<Skeleton width="100%" height="1rem" class="rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,16 +180,16 @@ onMounted(() => {
|
|||||||
<!-- Recent Videos Skeleton -->
|
<!-- Recent Videos Skeleton -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<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>
|
<Skeleton width="8rem" height="1.5rem" class="rounded" />
|
||||||
<Skeleton width="5rem" height="1rem"></Skeleton>
|
<Skeleton width="5rem" height="1rem" class="rounded" />
|
||||||
</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>
|
<Skeleton width="4rem" height="2.5rem" class="rounded" />
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
<Skeleton width="30%" height="1rem" class="rounded" />
|
||||||
<Skeleton width="20%" height="0.8rem"></Skeleton>
|
<Skeleton width="20%" height="0.8rem" class="rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<Toast />
|
<Toast />
|
||||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
<Form
|
||||||
class="flex flex-col gap-4 w-full">
|
:initialValues="initialValues"
|
||||||
|
:validators="validators"
|
||||||
|
@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">
|
<Field name="email" label="Email address">
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
<template #default="{ value, error, isInvalid }">
|
||||||
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
|
<Input name="email" type="email" placeholder="you@example.com" :modelValue="value" />
|
||||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||||
$form.email.error?.message }}</Message>
|
</template>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<Button type="submit" size="small" label="Send Reset Link" fluid />
|
<Button type="submit" label="Send Reset Link" />
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
<div class="text-center mt-2">
|
||||||
<router-link to="/login" replace
|
<router-link to="/login" replace
|
||||||
@@ -31,43 +35,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
|
||||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
|
||||||
import Toast from 'primevue/toast';
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { client } from '@/api/client';
|
import { client } from '@/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { Button, Field, Form, Input, Toast } from '@/components/ui/form';
|
||||||
import { useToast } from "primevue/usetoast";
|
import { inject, reactive } from 'vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const toast = inject<{ add: (t: any) => void }>('toast');
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
const initialValues = reactive({
|
const initialValues = reactive({
|
||||||
email: ''
|
email: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolver = zodResolver(
|
const validators = {
|
||||||
z.object({
|
email: [
|
||||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
|
(value: string) => !value ? 'Email is required.' : undefined,
|
||||||
})
|
(value: string) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email address.' : undefined,
|
||||||
);
|
],
|
||||||
|
|
||||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
|
||||||
if (valid) {
|
|
||||||
client.auth.forgotPasswordCreate({ email: values.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>
|
|
||||||
|
const onFormSubmit = (values: Record<string, any>) => {
|
||||||
|
client.auth.forgotPasswordCreate({ email: values.email })
|
||||||
|
.then(() => {
|
||||||
|
toast?.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast?.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import vueHead from "@/components/VueHead";
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<Toast />
|
<Toast />
|
||||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
<Form
|
||||||
class="flex flex-col gap-4 w-full">
|
:initialValues="initialValues"
|
||||||
<div class="flex flex-col gap-1">
|
:validators="validators"
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
|
@submit="onFormSubmit"
|
||||||
<InputText size="small" name="email" type="text" placeholder="Enter your email" fluid
|
class="flex flex-col gap-4 w-full"
|
||||||
:disabled="auth.loading" />
|
>
|
||||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
<Field name="email" label="Email">
|
||||||
$form.email.error?.message }}</Message>
|
<template #default="{ value, error, isInvalid }">
|
||||||
</div>
|
<Input
|
||||||
|
name="email"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
:modelValue="value"
|
||||||
|
:disabled="auth.loading"
|
||||||
|
/>
|
||||||
|
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||||
|
</template>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<Field name="password" label="Password">
|
||||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
<template #default="{ value, error, isInvalid }">
|
||||||
<Password name="password" size="small" placeholder="Enter your password" :feedback="false" toggleMask
|
<Input
|
||||||
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" />
|
name="password"
|
||||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
type="password"
|
||||||
$form.password.error?.message }}</Message>
|
placeholder="Enter your password"
|
||||||
</div>
|
:modelValue="value"
|
||||||
|
:disabled="auth.loading"
|
||||||
|
/>
|
||||||
|
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||||
|
</template>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<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" size="small" name="rememberMe" binary :disabled="auth.loading" />
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="initialValues.rememberMe"
|
||||||
|
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/20"
|
||||||
|
/>
|
||||||
<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 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" size="small" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid
|
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" :loading="auth.loading" />
|
||||||
:loading="auth.loading" />
|
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-0 flex items-center">
|
<div class="absolute inset-0 flex items-center">
|
||||||
@@ -43,39 +61,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="small" type="button" variant="outlined" severity="secondary"
|
<Button type="button" variant="outlined" label="Google" :loading="auth.loading" @click="loginWithGoogle">
|
||||||
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
|
<template #default>
|
||||||
<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
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<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 { Button, Field, Form, Input, Toast } from '@/components/ui/form';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
import { inject, reactive, watch } from 'vue';
|
||||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
|
||||||
import Toast from 'primevue/toast';
|
|
||||||
import { useToast } from "primevue/usetoast";
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { z } from 'zod';
|
|
||||||
const t = useToast();
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
// const $form = Form.useFormContext();
|
const toast = inject<{ add: (t: any) => void }>('toast');
|
||||||
|
|
||||||
watch(() => auth.error, (newError) => {
|
watch(() => auth.error, (newError) => {
|
||||||
if (newError) {
|
if (newError && toast) {
|
||||||
t.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
|
toast.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,18 +99,20 @@ const initialValues = reactive({
|
|||||||
rememberMe: false
|
rememberMe: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolver = zodResolver(
|
const validators = {
|
||||||
z.object({
|
email: [
|
||||||
email: z.string().min(1, { message: 'Email or username is required.' }),
|
(value: string) => !value ? 'Email or username is required.' : undefined,
|
||||||
password: z.string().min(1, { message: 'Password is required.' })
|
],
|
||||||
})
|
password: [
|
||||||
);
|
(value: string) => !value ? 'Password is required.' : undefined,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
|
const onFormSubmit = async (values: Record<string, any>) => {
|
||||||
if (valid) auth.login(values.email, values.password);
|
auth.login(values.email, values.password);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loginWithGoogle = () => {
|
const loginWithGoogle = () => {
|
||||||
auth.loginWithGoogle();
|
auth.loginWithGoogle();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,50 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
<Form
|
||||||
class="flex flex-col gap-4 w-full">
|
:initialValues="initialValues"
|
||||||
<div class="flex flex-col gap-1">
|
:validators="validators"
|
||||||
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
|
@submit="onFormSubmit"
|
||||||
<InputText size="small" name="name" placeholder="John Doe" fluid />
|
class="flex flex-col gap-4 w-full"
|
||||||
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
|
>
|
||||||
$form.name.error?.message }}</Message>
|
<Field name="name" label="Full Name">
|
||||||
</div>
|
<template #default="{ value, error, isInvalid }">
|
||||||
|
<Input name="name" type="text" placeholder="John Doe" :modelValue="value" />
|
||||||
|
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||||
|
</template>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<Field name="email" label="Email address">
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
<template #default="{ value, error, isInvalid }">
|
||||||
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
|
<Input name="email" type="email" placeholder="you@example.com" :modelValue="value" />
|
||||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||||
$form.email.error?.message }}</Message>
|
</template>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<Field name="password" label="Password">
|
||||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
<template #default="{ value, error, isInvalid }">
|
||||||
<Password name="password" size="small" placeholder="Create a password" :feedback="true" toggleMask fluid
|
<Input name="password" type="password" placeholder="Create a password" :modelValue="value" />
|
||||||
:inputStyle="{ width: '100%' }" />
|
<small class="text-gray-500">Must be at least 8 characters.</small>
|
||||||
<small class="text-gray-500">Must be at least 8 characters.</small>
|
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
</template>
|
||||||
$form.password.error?.message }}</Message>
|
</Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" size="small" label="Create Account" fluid />
|
<Button type="submit" label="Create Account" />
|
||||||
|
|
||||||
<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" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign in</router-link>
|
||||||
in</router-link>
|
|
||||||
</p>
|
</p>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Button, Field, Form, Input } from '@/components/ui/form';
|
||||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
|
||||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
@@ -54,17 +51,21 @@ const initialValues = reactive({
|
|||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolver = zodResolver(
|
const validators = {
|
||||||
z.object({
|
name: [
|
||||||
name: z.string().min(1, { message: 'Name is required.' }),
|
(value: string) => !value ? 'Name is required.' : undefined,
|
||||||
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.' })
|
email: [
|
||||||
})
|
(value: string) => !value ? 'Email is required.' : undefined,
|
||||||
);
|
(value: string) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email address.' : undefined,
|
||||||
|
],
|
||||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
password: [
|
||||||
if (valid) {
|
(value: string) => !value ? 'Password is required.' : undefined,
|
||||||
auth.register(values.name, values.email, values.password);
|
(value: string) => value.length < 8 ? 'Password must be at least 8 characters.' : undefined,
|
||||||
}
|
],
|
||||||
};
|
};
|
||||||
</script>
|
|
||||||
|
const onFormSubmit = (values: Record<string, any>) => {
|
||||||
|
auth.register(values.name, values.email, values.password);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Chart from '@/components/icons/Chart.vue';
|
|||||||
import Credit from '@/components/icons/Credit.vue';
|
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 Skeleton from 'primevue/skeleton';
|
import { Skeleton } from '@/components/ui/form';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import Referral from './Referral.vue';
|
import Referral from './Referral.vue';
|
||||||
|
|
||||||
@@ -45,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>
|
<Skeleton width="10rem" height="1.5rem" class="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>
|
<Skeleton width="3rem" height="3rem" borderRadius="9999px" class="mb-4" />
|
||||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
<Skeleton width="8rem" height="1.25rem" class="mb-2" />
|
||||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
<Skeleton width="100%" height="1rem" />
|
||||||
</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>
|
<Skeleton width="10rem" height="2rem" />
|
||||||
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton>
|
<Skeleton width="100%" height="1.25rem" class="my-4" />
|
||||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
<Skeleton width="100%" height="1rem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { Skeleton } from '@/components/ui/form';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import { formatDate, formatDuration } from '@/lib/utils';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||||
|
import { Skeleton } from '@/components/ui/form';
|
||||||
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 +22,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>
|
<Skeleton width="5rem" height="1rem" class="mb-2" />
|
||||||
<Skeleton width="8rem" height="2rem"></Skeleton>
|
<Skeleton width="8rem" height="2rem" />
|
||||||
</div>
|
</div>
|
||||||
<!-- <Skeleton shape="circle" size="3rem"></Skeleton> -->
|
|
||||||
</div>
|
</div>
|
||||||
<Skeleton width="4rem" height="1rem"></Skeleton>
|
<Skeleton width="4rem" height="1rem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { client, type ModelPlan } from '@/api/client';
|
import { client, type ModelPlan } from '@/api/client';
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import useSWRV from '@/lib/swr';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import CurrentPlanCard from './components/CurrentPlanCard.vue';
|
import CurrentPlanCard from './components/CurrentPlanCard.vue';
|
||||||
import UsageStatsCard from './components/UsageStatsCard.vue';
|
|
||||||
import PlanList from './components/PlanList.vue';
|
|
||||||
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
|
||||||
import EditPlanDialog from './components/EditPlanDialog.vue';
|
import EditPlanDialog from './components/EditPlanDialog.vue';
|
||||||
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
||||||
|
import PlanList from './components/PlanList.vue';
|
||||||
|
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
||||||
|
import UsageStatsCard from './components/UsageStatsCard.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
// const plans = ref<ModelPlan[]>([]);
|
|
||||||
const subscribing = ref<string | null>(null);
|
const subscribing = ref<string | null>(null);
|
||||||
const showManageDialog = ref(false);
|
const showManageDialog = ref(false);
|
||||||
const cancelling = ref(false);
|
const cancelling = ref(false);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const plansData = ref<any>(null);
|
||||||
|
|
||||||
// Mock Payment History Data
|
// Mock Payment History Data
|
||||||
const paymentHistory = ref([
|
const paymentHistory = ref([
|
||||||
@@ -24,38 +24,39 @@ const paymentHistory = ref([
|
|||||||
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
||||||
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
||||||
]);
|
]);
|
||||||
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", client.plans.plansList)
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
plansData.value = await client.plans.plansList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch plans', e);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPlans();
|
||||||
|
});
|
||||||
|
|
||||||
// Computed Usage (Mock if not in store)
|
// Computed Usage (Mock if not in store)
|
||||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||||
// Default limit 10GB if no plan
|
|
||||||
const storageLimit = computed(() => 10737418240);
|
const storageLimit = computed(() => 10737418240);
|
||||||
const uploadsUsed = ref(12);
|
const uploadsUsed = ref(12);
|
||||||
const uploadsLimit = ref(50);
|
const uploadsLimit = ref(50);
|
||||||
|
|
||||||
const currentPlanId = computed(() => {
|
const currentPlanId = computed(() => {
|
||||||
if (auth.user?.plan_id) return auth.user.plan_id;
|
if (auth.user?.plan_id) return auth.user.plan_id;
|
||||||
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id; // Fallback to first plan
|
if (Array.isArray(plansData.value?.data?.data?.plans) && plansData.value?.data?.data?.plans.length > 0) return plansData.value.data.data.plans[0].id;
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPlan = computed(() => {
|
const currentPlan = computed(() => {
|
||||||
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
|
if (!Array.isArray(plansData.value?.data?.data?.plans)) return undefined;
|
||||||
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
|
return plansData.value.data.data.plans.find((p: ModelPlan) => p.id === currentPlanId.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// watch(data, (newValue) => {
|
|
||||||
// if (newValue) {
|
|
||||||
// // Handle potentially different response structures
|
|
||||||
// // Safe access to avoid SSR crash if data is null/undefined
|
|
||||||
// const plansList = newValue?.data?.data?.plans;
|
|
||||||
// if (Array.isArray(plansList)) {
|
|
||||||
// plans.value = plansList;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }, { immediate: true });
|
|
||||||
|
|
||||||
const showEditDialog = ref(false);
|
const showEditDialog = ref(false);
|
||||||
const editingPlan = ref<ModelPlan>({});
|
const editingPlan = ref<ModelPlan>({});
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
@@ -70,27 +71,18 @@ const savePlan = async (updatedPlan: ModelPlan) => {
|
|||||||
try {
|
try {
|
||||||
if (!updatedPlan.id) return;
|
if (!updatedPlan.id) return;
|
||||||
|
|
||||||
// Optimistic update or API call
|
|
||||||
await client.request({
|
await client.request({
|
||||||
path: `/plans/${updatedPlan.id}`,
|
path: `/plans/${updatedPlan.id}`,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: updatedPlan
|
body: updatedPlan
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh plans
|
await fetchPlans();
|
||||||
await mutatePlans();
|
|
||||||
|
|
||||||
showEditDialog.value = false;
|
showEditDialog.value = false;
|
||||||
alert('Plan updated successfully');
|
alert('Plan updated successfully');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Failed to update plan', e);
|
console.error('Failed to update plan', e);
|
||||||
// Fallback: update local state if API is mocked/missing
|
|
||||||
const idx = data.value!.data.data.plans.findIndex(p => p.id === updatedPlan.id);
|
|
||||||
if (idx !== -1) {
|
|
||||||
data.value!.data.data.plans[idx] = { ...updatedPlan };
|
|
||||||
}
|
|
||||||
showEditDialog.value = false;
|
showEditDialog.value = false;
|
||||||
// alert('Note: API update failed, updated locally. ' + e.message);
|
|
||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false;
|
isSaving.value = false;
|
||||||
}
|
}
|
||||||
@@ -104,8 +96,6 @@ const subscribe = async (plan: ModelPlan) => {
|
|||||||
amount: plan.price || 0,
|
amount: plan.price || 0,
|
||||||
plan_id: plan.id
|
plan_id: plan.id
|
||||||
});
|
});
|
||||||
// Update local state mock
|
|
||||||
// In real app, we would re-fetch user profile
|
|
||||||
alert(`Successfully subscribed to ${plan.name}`);
|
alert(`Successfully subscribed to ${plan.name}`);
|
||||||
|
|
||||||
paymentHistory.value.unshift({
|
paymentHistory.value.unshift({
|
||||||
@@ -127,7 +117,6 @@ const subscribe = async (plan: ModelPlan) => {
|
|||||||
const cancelSubscription = async () => {
|
const cancelSubscription = async () => {
|
||||||
cancelling.value = true;
|
cancelling.value = true;
|
||||||
try {
|
try {
|
||||||
// Simulate API call
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
alert('Subscription has been canceled.');
|
alert('Subscription has been canceled.');
|
||||||
showManageDialog.value = false;
|
showManageDialog.value = false;
|
||||||
@@ -168,7 +157,7 @@ const cancelSubscription = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlanList
|
<PlanList
|
||||||
:plans="data?.data?.data.plans || []"
|
:plans="plansData?.data?.data?.plans || []"
|
||||||
:is-loading="!!isLoading"
|
:is-loading="!!isLoading"
|
||||||
:current-plan-id="currentPlanId"
|
:current-plan-id="currentPlanId"
|
||||||
:subscribing-plan-id="subscribing"
|
:subscribing-plan-id="subscribing"
|
||||||
@@ -195,4 +184,3 @@ const cancelSubscription = async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type ModelPlan } from '@/api/client';
|
import { type ModelPlan } from '@/api/client';
|
||||||
import Button from 'primevue/button';
|
import { Button, Tag } from '@/components/ui/form';
|
||||||
import Tag from 'primevue/tag';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentPlan?: ModelPlan;
|
currentPlan?: ModelPlan;
|
||||||
@@ -13,7 +12,7 @@ defineEmits<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class=":uno: lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8">
|
<div class="lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8">
|
||||||
<!-- Background decorations -->
|
<!-- Background decorations -->
|
||||||
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full blur-3xl opacity-20"></div>
|
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full blur-3xl opacity-20"></div>
|
||||||
<div class="absolute bottom-0 left-0 -mb-16 -ml-16 w-64 h-64 bg-purple-500 rounded-full blur-3xl opacity-20"></div>
|
<div class="absolute bottom-0 left-0 -mb-16 -ml-16 w-64 h-64 bg-purple-500 rounded-full blur-3xl opacity-20"></div>
|
||||||
@@ -23,7 +22,7 @@ defineEmits<{
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
|
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
|
||||||
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
|
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
|
||||||
<Tag value="Active" severity="success" class="px-3" rounded></Tag>
|
<Tag value="Active" severity="success" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-3xl font-bold text-white">${{ currentPlan?.price || 0 }}<span class="text-lg text-gray-400 font-normal">/mo</span></div>
|
<div class="text-3xl font-bold text-white">${{ currentPlan?.price || 0 }}<span class="text-lg text-gray-400 font-normal">/mo</span></div>
|
||||||
@@ -32,7 +31,7 @@ defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 pt-8 border-t border-gray-700/50 flex gap-4">
|
<div class="mt-8 pt-8 border-t border-gray-700/50 flex gap-4">
|
||||||
<Button label="Manage Subscription" severity="secondary" class="bg-white/10 border-white/10 text-white hover:bg-white/20" @click="$emit('manage')" />
|
<Button label="Manage Subscription" variant="secondary" @click="$emit('manage')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type ModelPlan } from '@/api/client';
|
import { type ModelPlan } from '@/api/client';
|
||||||
import Button from 'primevue/button';
|
import { Button, Dialog } from '@/components/ui/form';
|
||||||
import Checkbox from 'primevue/checkbox';
|
|
||||||
import Dialog from 'primevue/dialog';
|
|
||||||
import InputNumber from 'primevue/inputnumber';
|
|
||||||
import InputText from 'primevue/inputtext';
|
|
||||||
import Textarea from 'primevue/textarea';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -38,53 +33,100 @@ const visibleModel = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:visible="visibleModel" modal header="Edit Plan" :style="{ width: '40rem' }">
|
<Dialog v-model:visible="visibleModel" header="Edit Plan" :style="{ width: '40rem' }">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-name" class="text-sm font-medium text-gray-700">Name</label>
|
<label for="plan-name" class="text-sm font-medium text-gray-700">Name</label>
|
||||||
<InputText id="plan-name" v-model="localPlan.name" placeholder="Plan Name" />
|
<input
|
||||||
|
id="plan-name"
|
||||||
|
v-model="localPlan.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Plan Name"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-price" class="text-sm font-medium text-gray-700">Price ($)</label>
|
<label for="plan-price" class="text-sm font-medium text-gray-700">Price ($)</label>
|
||||||
<InputNumber id="plan-price" v-model="localPlan.price" mode="currency" currency="USD" locale="en-US" :minFractionDigits="2" />
|
<input
|
||||||
|
id="plan-price"
|
||||||
|
v-model="localPlan.price"
|
||||||
|
type="number"
|
||||||
|
placeholder="Price"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
|
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
|
||||||
<InputText id="plan-cycle" v-model="localPlan.cycle" placeholder="e.g. month, year" />
|
<input
|
||||||
|
id="plan-cycle"
|
||||||
|
v-model="localPlan.cycle"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. month, year"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-desc" class="text-sm font-medium text-gray-700">Description</label>
|
<label for="plan-desc" class="text-sm font-medium text-gray-700">Description</label>
|
||||||
<Textarea id="plan-desc" v-model="localPlan.description" rows="2" class="w-full" />
|
<textarea
|
||||||
|
id="plan-desc"
|
||||||
|
v-model="localPlan.description"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Description"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-storage" class="text-sm font-medium text-gray-700">Storage Limit (bytes)</label>
|
<label for="plan-storage" class="text-sm font-medium text-gray-700">Storage Limit (bytes)</label>
|
||||||
<InputNumber id="plan-storage" v-model="localPlan.storage_limit" />
|
<input
|
||||||
|
id="plan-storage"
|
||||||
|
v-model="localPlan.storage_limit"
|
||||||
|
type="number"
|
||||||
|
placeholder="Storage limit"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-uploads" class="text-sm font-medium text-gray-700">Upload Limit (per day)</label>
|
<label for="plan-uploads" class="text-sm font-medium text-gray-700">Upload Limit (per day)</label>
|
||||||
<InputNumber id="plan-uploads" v-model="localPlan.upload_limit" />
|
<input
|
||||||
|
id="plan-uploads"
|
||||||
|
v-model="localPlan.upload_limit"
|
||||||
|
type="number"
|
||||||
|
placeholder="Upload limit"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
|
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
|
||||||
<InputNumber id="plan-duration" v-model="localPlan.duration_limit" />
|
<input
|
||||||
|
id="plan-duration"
|
||||||
|
v-model="localPlan.duration_limit"
|
||||||
|
type="number"
|
||||||
|
placeholder="Duration limit"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 pt-2">
|
<div class="flex items-center gap-2 pt-2">
|
||||||
<Checkbox v-model="localPlan.is_active" :binary="true" inputId="plan-active" />
|
<input
|
||||||
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
|
type="checkbox"
|
||||||
</div>
|
id="plan-active"
|
||||||
|
v-model="localPlan.is_active"
|
||||||
|
class="w-4 h-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancel" text severity="secondary" @click="visibleModel = false" />
|
<Button variant="secondary" label="Cancel" @click="visibleModel = false" />
|
||||||
<Button label="Save Changes" icon="i-heroicons-check" @click="onSave" :loading="loading" />
|
<Button label="Save Changes" @click="onSave" :loading="loading" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type ModelPlan } from '@/api/client';
|
import { type ModelPlan } from '@/api/client';
|
||||||
import Button from 'primevue/button';
|
import { Button, Dialog } from '@/components/ui/form';
|
||||||
import Dialog from 'primevue/dialog';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -22,7 +21,7 @@ const visibleModel = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:visible="visibleModel" modal header="Manage Subscription" :style="{ width: '30rem' }">
|
<Dialog v-model:visible="visibleModel" header="Manage Subscription" :style="{ width: '30rem' }">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p class="text-gray-600 mb-4">You are currently subscribed to <span class="font-bold text-gray-900">{{ currentPlan?.name }}</span>.</p>
|
<p class="text-gray-600 mb-4">You are currently subscribed to <span class="font-bold text-gray-900">{{ currentPlan?.name }}</span>.</p>
|
||||||
<div class="bg-gray-50 p-4 rounded-lg space-y-2 border border-gray-200">
|
<div class="bg-gray-50 p-4 rounded-lg space-y-2 border border-gray-200">
|
||||||
@@ -44,11 +43,9 @@ const visibleModel = computed({
|
|||||||
Canceling your subscription will downgrade you to the Free plan at the end of your current billing period.
|
Canceling your subscription will downgrade you to the Free plan at the end of your current billing period.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button label="Close" text severity="secondary" @click="visibleModel = false" />
|
<Button variant="secondary" label="Close" @click="visibleModel = false" />
|
||||||
<Button
|
<Button
|
||||||
label="Cancel Subscription"
|
label="Cancel Subscription"
|
||||||
severity="danger"
|
|
||||||
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
|
|
||||||
@click="emit('cancel-subscription')"
|
@click="emit('cancel-subscription')"
|
||||||
:disabled="cancelling"
|
:disabled="cancelling"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type ModelPlan } from '@/api/client';
|
import { type ModelPlan } from '@/api/client';
|
||||||
import Button from 'primevue/button';
|
import { Button, Skeleton } from '@/components/ui/form';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { formatBytes } from '@/lib/utils'; // Using utils formatBytes
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
plans: ModelPlan[];
|
plans: ModelPlan[];
|
||||||
@@ -40,7 +39,7 @@ const isCurrentComp = (plan: ModelPlan, currentId?: string) => {
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div v-for="i in 3" :key="i" class="h-full">
|
<div v-for="i in 3" :key="i" class="h-full">
|
||||||
<Skeleton height="300px" borderRadius="16px"></Skeleton>
|
<Skeleton height="300px" borderRadius="16px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,11 +52,8 @@ const isCurrentComp = (plan: ModelPlan, currentId?: string) => {
|
|||||||
<!-- Admin Edit Button -->
|
<!-- Admin Edit Button -->
|
||||||
<Button
|
<Button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
icon="i-heroicons-pencil-square"
|
class="absolute top-2 right-2 z-20"
|
||||||
class="absolute top-2 right-2 z-20 !p-2 !w-8 !h-8"
|
variant="secondary"
|
||||||
severity="secondary"
|
|
||||||
text
|
|
||||||
rounded
|
|
||||||
@click.stop="emit('edit', plan)"
|
@click.stop="emit('edit', plan)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -93,10 +89,8 @@ const isCurrentComp = (plan: ModelPlan, currentId?: string) => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
:label="isCurrentComp(plan, currentPlanId) ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade')"
|
:label="isCurrentComp(plan, currentPlanId) ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade')"
|
||||||
:icon="subscribingPlanId === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:severity="isCurrentComp(plan, currentPlanId) ? 'secondary' : 'primary'"
|
:variant="isCurrentComp(plan, currentPlanId) ? 'outlined' : 'primary'"
|
||||||
:outlined="isCurrentComp(plan, currentPlanId)"
|
|
||||||
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
|
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
|
||||||
@click="emit('subscribe', plan)"
|
@click="emit('subscribe', plan)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button';
|
import { Tag } from '@/components/ui/form';
|
||||||
import Column from 'primevue/column';
|
import { inject } from 'vue';
|
||||||
import DataTable from 'primevue/datatable';
|
|
||||||
import Tag from 'primevue/tag';
|
|
||||||
|
|
||||||
interface PaymentHistoryItem {
|
interface PaymentHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,36 +15,31 @@ defineProps<{
|
|||||||
history: PaymentHistoryItem[];
|
history: PaymentHistoryItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const getStatusSeverity = (status: string) => {
|
const getStatusSeverity = (status: string): 'success' | 'error' | 'warn' | 'info' | 'secondary' => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'success';
|
return 'success';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return 'danger';
|
return 'error';
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'warn';
|
return 'warn';
|
||||||
default:
|
default:
|
||||||
return 'info';
|
return 'info';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
import { useToast } from 'primevue/usetoast';
|
|
||||||
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
|
|
||||||
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
|
|
||||||
|
const toast = inject<{ add: (t: any) => void }>('toast');
|
||||||
|
|
||||||
const downloadInvoice = (item: PaymentHistoryItem) => {
|
const downloadInvoice = (item: PaymentHistoryItem) => {
|
||||||
toast.add({
|
toast?.add({
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
summary: 'Downloading',
|
summary: 'Downloading',
|
||||||
detail: `Downloading invoice #${item.invoiceId}...`,
|
detail: `Downloading invoice #${item.invoiceId}...`,
|
||||||
life: 2000
|
life: 2000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate download delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.add({
|
toast?.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Downloaded',
|
summary: 'Downloaded',
|
||||||
detail: `Invoice #${item.invoiceId} downloaded successfully`,
|
detail: `Invoice #${item.invoiceId} downloaded successfully`,
|
||||||
@@ -60,34 +53,31 @@ const downloadInvoice = (item: PaymentHistoryItem) => {
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Billing History</h2>
|
<h2 class="text-2xl font-bold mb-6 text-gray-900">Billing History</h2>
|
||||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||||
<DataTable :value="history" responsiveLayout="scroll" class="w-full">
|
<div class="overflow-x-auto">
|
||||||
<template #empty>
|
<table class="w-full">
|
||||||
<div class="text-center py-8 text-gray-500">No payment history found.</div>
|
<thead>
|
||||||
</template>
|
<tr class="border-b border-gray-200 bg-gray-50">
|
||||||
<Column field="date" header="Date" class="font-medium"></Column>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
<Column field="amount" header="Amount">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||||
<template #body="slotProps">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
||||||
${{ slotProps.data.amount }}
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
</template>
|
</tr>
|
||||||
</Column>
|
</thead>
|
||||||
<Column field="plan" header="Plan"></Column>
|
<tbody class="divide-y divide-gray-100">
|
||||||
<Column field="status" header="Status">
|
<tr v-for="item in history" :key="item.id">
|
||||||
<template #body="slotProps">
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ item.date }}</td>
|
||||||
<Tag :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)"
|
<td class="px-4 py-3 text-sm text-gray-900">${{ item.amount }}</td>
|
||||||
class="capitalize px-2 py-0.5 text-xs" :rounded="true" />
|
<td class="px-4 py-3 text-sm text-gray-500">{{ item.plan }}</td>
|
||||||
</template>
|
<td class="px-4 py-3">
|
||||||
</Column>
|
<Tag :value="item.status" :severity="getStatusSeverity(item.status)" />
|
||||||
<!-- <Column header="" style="width: 3rem">
|
</td>
|
||||||
<template #body="slotProps">
|
</tr>
|
||||||
<Button text rounded severity="secondary" size="small" @click="downloadInvoice(slotProps.data)"
|
<tr v-if="history.length === 0">
|
||||||
v-tooltip="'Download Invoice'">
|
<td colspan="4" class="px-4 py-8 text-center text-gray-500">No payment history found.</td>
|
||||||
<template #icon>
|
</tr>
|
||||||
<ArrowDownTray class="w-5 h-5" />
|
</tbody>
|
||||||
</template>
|
</table>
|
||||||
</Button>
|
</div>
|
||||||
</template>
|
|
||||||
</Column> -->
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ProgressBar } from '@/components/ui/form';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import ProgressBar from 'primevue/progressbar';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -23,7 +23,7 @@ const uploadsPercentage = computed(() => Math.min(Math.round((props.uploadsUsed
|
|||||||
<span class="text-gray-600 font-medium">Storage</span>
|
<span class="text-gray-600 font-medium">Storage</span>
|
||||||
<span class="text-gray-900 font-bold">{{ storagePercentage }}%</span>
|
<span class="text-gray-900 font-bold">{{ storagePercentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 8px" :class="storagePercentage > 90 ? 'p-progressbar-danger' : ''"></ProgressBar>
|
<ProgressBar :value="storagePercentage" />
|
||||||
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ const uploadsPercentage = computed(() => Math.min(Math.round((props.uploadsUsed
|
|||||||
<span class="text-gray-600 font-medium">Monthly Uploads</span>
|
<span class="text-gray-600 font-medium">Monthly Uploads</span>
|
||||||
<span class="text-gray-900 font-bold">{{ uploadsPercentage }}%</span>
|
<span class="text-gray-900 font-bold">{{ uploadsPercentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar :value="uploadsPercentage" :showValue="false" style="height: 8px"></ProgressBar>
|
<ProgressBar :value="uploadsPercentage" />
|
||||||
<p class="text-xs text-gray-500 mt-2">{{ uploadsUsed }} of {{ uploadsLimit }} uploads</p>
|
<p class="text-xs text-gray-500 mt-2">{{ uploadsUsed }} of {{ uploadsLimit }} uploads</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { computed, inject, ref } from 'vue';
|
||||||
|
import AccountStatusCard from './components/AccountStatusCard.vue';
|
||||||
|
import ChangePasswordDialog from './components/ChangePasswordDialog.vue';
|
||||||
|
import LinkedAccountsCard from './components/LinkedAccountsCard.vue';
|
||||||
import ProfileHero from './components/ProfileHero.vue';
|
import ProfileHero from './components/ProfileHero.vue';
|
||||||
import ProfileInfoCard from './components/ProfileInfoCard.vue';
|
import ProfileInfoCard from './components/ProfileInfoCard.vue';
|
||||||
import ChangePasswordDialog from './components/ChangePasswordDialog.vue';
|
|
||||||
import AccountStatusCard from './components/AccountStatusCard.vue';
|
|
||||||
import LinkedAccountsCard from './components/LinkedAccountsCard.vue';
|
|
||||||
import { useToast } from 'primevue/usetoast';
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const toast = useToast();
|
const toast = inject<{ add: (t: any) => void }>('toast');
|
||||||
|
|
||||||
// Dialog visibility
|
// Dialog visibility
|
||||||
const showPasswordDialog = ref(false);
|
const showPasswordDialog = ref(false);
|
||||||
|
|
||||||
// Refs for dialog components
|
// Refs for dialog components
|
||||||
const passwordDialogRef = ref<InstanceType<typeof ChangePasswordDialog>>();
|
const passwordDialogRef = ref<any>();
|
||||||
|
|
||||||
// Computed storage values
|
// Computed storage values
|
||||||
const storageUsed = computed(() => auth.user?.storage_used || 0);
|
const storageUsed = computed(() => auth.user?.storage_used || 0);
|
||||||
@@ -26,14 +25,14 @@ const storageLimit = computed(() => 10737418240); // 10GB default
|
|||||||
const handleEditSave = async (data: { username: string; email: string }) => {
|
const handleEditSave = async (data: { username: string; email: string }) => {
|
||||||
try {
|
try {
|
||||||
await auth.updateProfile(data);
|
await auth.updateProfile(data);
|
||||||
toast.add({
|
toast?.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Profile Updated',
|
summary: 'Profile Updated',
|
||||||
detail: 'Your profile has been updated successfully.',
|
detail: 'Your profile has been updated successfully.',
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({
|
toast?.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: 'Update Failed',
|
summary: 'Update Failed',
|
||||||
detail: auth.error || 'Failed to update profile.',
|
detail: auth.error || 'Failed to update profile.',
|
||||||
@@ -46,14 +45,16 @@ const handlePasswordSave = async (data: { currentPassword: string; newPassword:
|
|||||||
try {
|
try {
|
||||||
await auth.changePassword(data.currentPassword, data.newPassword);
|
await auth.changePassword(data.currentPassword, data.newPassword);
|
||||||
showPasswordDialog.value = false;
|
showPasswordDialog.value = false;
|
||||||
toast.add({
|
toast?.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Password Changed',
|
summary: 'Password Changed',
|
||||||
detail: 'Your password has been changed successfully.',
|
detail: 'Your password has been changed successfully.',
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
passwordDialogRef.value?.setError(e.message || 'Failed to change password');
|
if (passwordDialogRef.value?.setError) {
|
||||||
|
passwordDialogRef.value.setError(e.message || 'Failed to change password');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ProgressBar from 'primevue/progressbar';
|
import { ProgressBar } from '@/components/ui/form';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -29,7 +29,7 @@ const formatBytes = (bytes: number) => {
|
|||||||
<span class="text-gray-600">Storage Used</span>
|
<span class="text-gray-600">Storage Used</span>
|
||||||
<span class="font-bold text-gray-900">{{ storagePercentage }}%</span>
|
<span class="font-bold text-gray-900">{{ storagePercentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px"></ProgressBar>
|
<ProgressBar :value="storagePercentage" />
|
||||||
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-green-50 rounded-lg p-4 border border-green-100 flex items-start gap-3">
|
<div class="bg-green-50 rounded-lg p-4 border border-green-100 flex items-start gap-3">
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Dialog from 'primevue/dialog';
|
import { Button, Dialog, Input } from '@/components/ui/form';
|
||||||
import InputText from 'primevue/inputtext';
|
import { computed, ref, watch } from 'vue';
|
||||||
import Button from 'primevue/button';
|
|
||||||
import Message from 'primevue/message';
|
|
||||||
import { ref, computed, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -65,36 +62,53 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal header="Change Password"
|
<Dialog
|
||||||
:style="{ width: '28rem' }" :closable="true" :draggable="false">
|
:visible="visible"
|
||||||
|
@update:visible="emit('update:visible', $event)"
|
||||||
|
header="Change Password"
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
:closable="true"
|
||||||
|
>
|
||||||
<div class="space-y-6 pt-2">
|
<div class="space-y-6 pt-2">
|
||||||
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
|
<div v-if="error" class="p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="current-password" class="text-sm font-medium text-gray-700">Current Password</label>
|
<label for="current-password" class="text-sm font-medium text-gray-700">Current Password</label>
|
||||||
<InputText id="current-password" v-model="currentPassword" type="password" class="w-full"
|
<Input
|
||||||
placeholder="Enter current password" />
|
id="current-password"
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="new-password" class="text-sm font-medium text-gray-700">New Password</label>
|
<label for="new-password" class="text-sm font-medium text-gray-700">New Password</label>
|
||||||
<InputText id="new-password" v-model="newPassword" type="password" class="w-full"
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
placeholder="Enter new password (min 6 characters)"
|
placeholder="Enter new password (min 6 characters)"
|
||||||
:class="{ 'p-invalid': passwordTooShort }" />
|
/>
|
||||||
<small v-if="passwordTooShort" class="text-red-500">Password must be at least 6 characters</small>
|
<small v-if="passwordTooShort" class="text-red-500">Password must be at least 6 characters</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="confirm-password" class="text-sm font-medium text-gray-700">Confirm New Password</label>
|
<label for="confirm-password" class="text-sm font-medium text-gray-700">Confirm New Password</label>
|
||||||
<InputText id="confirm-password" v-model="confirmPassword" type="password" class="w-full"
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
:class="{ 'p-invalid': passwordMismatch }" />
|
/>
|
||||||
<small v-if="passwordMismatch" class="text-red-500">Passwords do not match</small>
|
<small v-if="passwordMismatch" class="text-red-500">Passwords do not match</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
<Button label="Cancel" severity="secondary" @click="handleClose" :disabled="loading" />
|
<Button variant="secondary" label="Cancel" @click="handleClose" :disabled="loading" />
|
||||||
<Button label="Change Password" @click="handleSave" :loading="loading" :disabled="!isValid" />
|
<Button label="Change Password" @click="handleSave" :loading="loading" :disabled="!isValid" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Tag from 'primevue/tag';
|
import { Tag } from '@/components/ui/form';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -18,7 +18,7 @@ import Tag from 'primevue/tag';
|
|||||||
</div>
|
</div>
|
||||||
<span class="font-medium text-gray-700">Google</span>
|
<span class="font-medium text-gray-700">Google</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag value="Connected" severity="success" class="text-xs px-2"></Tag>
|
<Tag value="Connected" severity="success" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelUser } from '@/api/client';
|
import type { ModelUser } from '@/api/client';
|
||||||
import Avatar from 'primevue/avatar';
|
import { Avatar, Button, Tag } from '@/components/ui/form';
|
||||||
import Button from 'primevue/button';
|
|
||||||
import Tag from 'primevue/tag';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -32,20 +30,17 @@ const joinDate = computed(() => {
|
|||||||
<div class="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
<div class="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-0 bg-primary-500 rounded-full blur-lg opacity-40"></div>
|
<div class="absolute inset-0 bg-primary-500 rounded-full blur-lg opacity-40"></div>
|
||||||
<!-- :label="user?.username?.charAt(0).toUpperCase() || 'U'" -->
|
|
||||||
<Avatar
|
<Avatar
|
||||||
class="relative border-4 border-gray-800 text-3xl font-bold bg-gradient-to-br from-primary-400 to-primary-600 text-white shadow-2xl"
|
size="large"
|
||||||
size="xlarge"
|
|
||||||
shape="circle"
|
shape="circle"
|
||||||
style="width: 120px; height: 120px; font-size: 3rem;"
|
label=""
|
||||||
image="https://picsum.photos/seed/user123/120/120.jpg"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center md:text-left space-y-2 flex-grow">
|
<div class="text-center md:text-left space-y-2 flex-grow">
|
||||||
<div class="flex flex-col md:flex-row items-center gap-3 justify-center md:justify-start">
|
<div class="flex flex-col md:flex-row items-center gap-3 justify-center md:justify-start">
|
||||||
<h2 class="text-3xl font-bold text-white">{{ user?.username || 'User' }}</h2>
|
<h2 class="text-3xl font-bold text-white">{{ user?.username || 'User' }}</h2>
|
||||||
<Tag :value="user?.role || 'User'" severity="info" class="uppercase tracking-wider px-2 header-tag" rounded></Tag>
|
<Tag :value="user?.role || 'User'" severity="info" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400 text-lg">{{ user?.email }}</p>
|
<p class="text-gray-400 text-lg">{{ user?.email }}</p>
|
||||||
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
|
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
|
||||||
@@ -60,8 +55,8 @@ const joinDate = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button label="Logout" severity="danger" class="border-white/10 text-white hover:bg-white/10 bg-white/5" @click="emit('logout')">
|
<Button label="Logout" @click="emit('logout')">
|
||||||
<template #icon>
|
<template #default>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" 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 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
<polyline points="16 17 21 12 16 7"/>
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
@@ -73,11 +68,3 @@ const joinDate = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.header-tag) {
|
|
||||||
background: rgba(255,255,255,0.2) !important;
|
|
||||||
color: white !important;
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelUser } from '@/api/client';
|
import type { ModelUser } from '@/api/client';
|
||||||
import Button from 'primevue/button';
|
import { Button } from '@/components/ui/form';
|
||||||
import InputText from 'primevue/inputtext';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
user: ModelUser | null;
|
user: ModelUser | null;
|
||||||
@@ -18,12 +17,13 @@ const emit = defineEmits<{
|
|||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
|
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button label="Change Password" text severity="secondary" @click="emit('changePassword')">
|
<Button variant="text" @click="emit('changePassword')">
|
||||||
<template #icon>
|
<template #default>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" 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 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Change Password
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,51 +31,39 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="username" class="text-sm font-medium text-gray-700">Username</label>
|
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<IconField>
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<InputIcon>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-400" 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-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
|
||||||
<circle cx="12" cy="7" r="4"/>
|
<circle cx="12" cy="7" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</InputIcon>
|
</div>
|
||||||
<InputText id="username" :value="user?.username" class="w-full pl-10" readonly />
|
<input
|
||||||
</IconField>
|
type="text"
|
||||||
|
:value="user?.username"
|
||||||
|
readonly
|
||||||
|
class="w-full pl-10 px-3 py-2 text-sm border border-gray-300 rounded-lg bg-gray-50 outline-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email Address</label>
|
<label class="text-sm font-medium text-gray-700">Email Address</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<IconField>
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<InputIcon>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-400" 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-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<rect width="20" height="16" x="2" y="4" rx="2"/>
|
||||||
<rect width="20" height="16" x="2" y="4" rx="2"/>
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
</svg>
|
||||||
</svg>
|
</div>
|
||||||
</InputIcon>
|
<input
|
||||||
<InputText id="email" :value="user?.email" class="w-full pl-10" readonly />
|
type="text"
|
||||||
</IconField>
|
:value="user?.email"
|
||||||
|
readonly
|
||||||
|
class="w-full pl-10 px-3 py-2 text-sm border border-gray-300 rounded-lg bg-gray-50 outline-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-6">
|
|
||||||
<!-- <div class="flex flex-col gap-2">
|
|
||||||
<label for="role" class="text-sm font-medium text-gray-700">Role</label>
|
|
||||||
<InputText id="role" :value="user?.role || 'User'" class="w-full capitalize bg-gray-50" readonly />
|
|
||||||
</div> -->
|
|
||||||
<!-- <div class="flex flex-col gap-2">
|
|
||||||
<label for="id" class="text-sm font-medium text-gray-700">User ID</label>
|
|
||||||
<InputText id="id" :value="user?.id || 'N/A'" class="w-full font-mono text-sm bg-gray-50" readonly />
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.p-inputtext[readonly]) {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
border-color: #e5e7eb;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
import type { ModelVideo } from '@/api/client';
|
||||||
import { formatDuration, formatDate, getStatusClass } from '@/lib/utils';
|
import { formatDate, formatDuration, getStatusClass } from '@/lib/utils';
|
||||||
import Checkbox from 'primevue/checkbox';
|
|
||||||
import Card from 'primevue/card';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
videos: ModelVideo[];
|
videos: ModelVideo[];
|
||||||
@@ -18,64 +15,63 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||||
<Card v-for="video in videos" :key="video.id"
|
<div v-for="video in videos" :key="video.id"
|
||||||
class="overflow-hidden shadow-sm hover:shadow-md transition-shadow group relative border border-gray-200"
|
class="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition-shadow group relative border border-gray-200"
|
||||||
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
:class="{ 'border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
||||||
|
|
||||||
|
<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) }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedVideos.some(v => v.id === video.id)"
|
||||||
|
@change="emit('update:selectedVideos', selectedVideos.some(v => v.id === video.id) ? selectedVideos.filter(v => v.id !== video.id) : [...selectedVideos, video])"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
<span class="i-heroicons-film text-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #header>
|
|
||||||
<div
|
<div
|
||||||
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
|
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||||
<!-- Grid Selection Checkbox -->
|
</div>
|
||||||
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
:class="{ 'opacity-100': selectedVideos.some(v => v.id === video.id) }">
|
|
||||||
<Checkbox :modelValue="selectedVideos" :value="video"
|
|
||||||
@update:modelValue="emit('update:selectedVideos', $event)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
<span
|
||||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
|
||||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
{{ formatDuration(video.duration) }}
|
||||||
<span class="i-heroicons-film text-3xl" />
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="p-4 flex flex-col h-full">
|
||||||
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
<div class="flex items-start justify-between gap-2 mb-1">
|
||||||
</div>
|
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
|
||||||
|
:title="video.title">
|
||||||
|
{{ video.title }}
|
||||||
|
</h3>
|
||||||
|
<button class="text-gray-400 hover:text-gray-700">
|
||||||
|
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-auto flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
|
:class="['px-1.5 py-0.5 text-[10px] font-medium rounded-full uppercase tracking-wider', getStatusClass(video.status)]">
|
||||||
{{ formatDuration(video.duration) }}
|
{{ video.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
<div class="text-[10px] text-gray-400">
|
||||||
<div class="flex flex-col h-full">
|
{{ formatDate(video.created_at) }}
|
||||||
<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">
|
|
||||||
{{ video.title }}
|
|
||||||
</h3>
|
|
||||||
<button class="text-gray-400 hover:text-gray-700">
|
|
||||||
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-auto flex items-center justify-between">
|
|
||||||
<span
|
|
||||||
:class="['px-1.5 py-0.5 text-[10px] font-medium rounded-full uppercase tracking-wider', getStatusClass(video.status)]">
|
|
||||||
{{ video.status }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="text-[10px] text-gray-400">
|
|
||||||
{{ formatDate(video.created_at) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
import type { ModelVideo } from '@/api/client';
|
||||||
import { formatDuration, formatDate, formatBytes, getStatusClass } from '@/lib/utils';
|
import { formatBytes, formatDate, formatDuration, getStatusClass } from '@/lib/utils';
|
||||||
import DataTable from 'primevue/datatable';
|
|
||||||
import Column from 'primevue/column';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
videos: ModelVideo[];
|
videos: ModelVideo[];
|
||||||
@@ -18,82 +15,83 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
<DataTable :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos"
|
<div class="overflow-x-auto">
|
||||||
@update:selection="emit('update:selectedVideos', $event)">
|
<table class="w-full min-w-[50rem]">
|
||||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 bg-gray-50">
|
||||||
<Column header="Video">
|
<th class="w-12 px-4 py-3 text-left">
|
||||||
<template #body="{ data }">
|
<input type="checkbox" class="rounded border-gray-300" />
|
||||||
<div class="flex items-center gap-3">
|
</th>
|
||||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Video</th>
|
||||||
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.title"
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
class="w-full h-full object-cover" />
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
|
||||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
|
||||||
|
<th class="w-32 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr v-for="video in videos" :key="video.id" class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedVideos.some(v => v.id === video.id)"
|
||||||
|
@change="emit('update:selectedVideos', selectedVideos.some(v => v.id === video.id) ? selectedVideos.filter(v => v.id !== video.id) : [...selectedVideos, video])"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
</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="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||||
|
class="w-full h-full object-cover" />
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{{ video.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
<div class="min-w-0 flex-1">
|
<td class="px-4 py-3">
|
||||||
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p>
|
<span
|
||||||
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
|
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
|
||||||
</div>
|
{{ video.status || 'Unknown' }}
|
||||||
</div>
|
</span>
|
||||||
</template>
|
</td>
|
||||||
</Column>
|
<td class="px-4 py-3 text-sm text-gray-500">{{ formatDuration(video.duration) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">{{ formatBytes(video.size) }}</td>
|
||||||
<Column header="Status">
|
<td class="px-4 py-3 text-sm text-gray-500">{{ formatDate(video.created_at) }}</td>
|
||||||
<template #body="{ data }">
|
<td class="px-4 py-3">
|
||||||
<span
|
<div class="flex items-center gap-1">
|
||||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(data.status)]">
|
<button
|
||||||
{{ data.status || 'Unknown' }}
|
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||||
</span>
|
title="Download">
|
||||||
</template>
|
<span class="i-heroicons-arrow-down-tray w-4 h-4" />
|
||||||
</Column>
|
</button>
|
||||||
|
<button
|
||||||
<Column header="Duration">
|
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||||
<template #body="{ data }">
|
title="Copy Link">
|
||||||
<span class="text-sm text-gray-500">{{ formatDuration(data.duration) }}</span>
|
<span class="i-heroicons-link w-4 h-4" />
|
||||||
</template>
|
</button>
|
||||||
</Column>
|
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
||||||
|
<button
|
||||||
<Column header="Size">
|
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||||
<template #body="{ data }">
|
title="Edit">
|
||||||
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
<span class="i-heroicons-pencil w-4 h-4" />
|
||||||
</template>
|
</button>
|
||||||
</Column>
|
<button @click="emit('delete', video.id!)"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
<Column header="Upload Date">
|
title="Delete">
|
||||||
<template #body="{ data }">
|
<span class="i-heroicons-trash w-4 h-4" />
|
||||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at) }}</span>
|
</button>
|
||||||
</template>
|
</div>
|
||||||
</Column>
|
</td>
|
||||||
|
</tr>
|
||||||
<Column header="Actions">
|
</tbody>
|
||||||
<template #body="{ data }">
|
</table>
|
||||||
<div class="flex items-center gap-1">
|
</div>
|
||||||
<button
|
|
||||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
|
||||||
title="Download">
|
|
||||||
<span class="i-heroicons-arrow-down-tray w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
|
||||||
title="Copy Link">
|
|
||||||
<span class="i-heroicons-link w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
|
||||||
<button
|
|
||||||
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
|
||||||
title="Edit">
|
|
||||||
<span class="i-heroicons-pencil w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button @click="emit('delete', data.id)"
|
|
||||||
class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
||||||
title="Delete">
|
|
||||||
<span class="i-heroicons-trash w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -178,10 +178,6 @@ export default defineConfig({
|
|||||||
DEFAULT: "#fafafa",
|
DEFAULT: "#fafafa",
|
||||||
light: "#f8f9fa",
|
light: "#f8f9fa",
|
||||||
},
|
},
|
||||||
muted: {
|
|
||||||
DEFAULT: "#f5f4f2",
|
|
||||||
light: "#f8f9fa",
|
|
||||||
},
|
|
||||||
border: {
|
border: {
|
||||||
DEFAULT: "#e6e7e2",
|
DEFAULT: "#e6e7e2",
|
||||||
light: "#f8f9fa",
|
light: "#f8f9fa",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
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";
|
||||||
import unocss from "unocss/vite";
|
import unocss from "unocss/vite";
|
||||||
import Components from "unplugin-vue-components/vite";
|
|
||||||
import AutoImport from "unplugin-auto-import/vite";
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import ssrPlugin from "./ssrPlugin";
|
import ssrPlugin from "./ssrPlugin";
|
||||||
export default defineConfig((env) => {
|
export default defineConfig((env) => {
|
||||||
@@ -15,18 +12,6 @@ export default defineConfig((env) => {
|
|||||||
unocss(),
|
unocss(),
|
||||||
vue(),
|
vue(),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
AutoImport({
|
|
||||||
imports: ["vue", "vue-router", "pinia"], // Common presets
|
|
||||||
dts: true, // Generate TypeScript declaration file
|
|
||||||
}),
|
|
||||||
Components({
|
|
||||||
dirs: ["src/components"],
|
|
||||||
extensions: ["vue", "tsx"],
|
|
||||||
dts: true,
|
|
||||||
dtsTsx: true,
|
|
||||||
directives: false,
|
|
||||||
resolvers: [PrimeVueResolver()],
|
|
||||||
}),
|
|
||||||
ssrPlugin(),
|
ssrPlugin(),
|
||||||
cloudflare(),
|
cloudflare(),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user