1 Commits

Author SHA1 Message Date
7f5bfc7a71 ai-vibe 2026-02-05 14:44:54 +07:00
55 changed files with 1559 additions and 2591 deletions

661
bun.lock

File diff suppressed because it is too large Load Diff

52
components.d.ts vendored
View File

@@ -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']

View File

@@ -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"
} }
} }

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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';

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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) => { const onFormSubmit = (values: Record<string, any>) => {
if (valid) { client.auth.forgotPasswordCreate({ email: values.email })
client.auth.forgotPasswordCreate({ email: values.email }) .then(() => {
.then(() => { toast?.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 }); })
}) .catch((error: any) => {
.catch((error) => { toast?.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 }); });
});
// forgotPassword(values.email).then(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// }).catch(() => {
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
// });
}
}; };
</script> </script>

View File

@@ -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();

View File

@@ -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,15 +99,17 @@ 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 = () => {

View File

@@ -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,
],
password: [
(value: string) => !value ? 'Password is required.' : undefined,
(value: string) => value.length < 8 ? 'Password must be at least 8 characters.' : undefined,
],
};
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const onFormSubmit = (values: Record<string, any>) => {
if (valid) { auth.register(values.name, values.email, values.password);
auth.register(values.name, values.email, values.password);
}
}; };
</script> </script>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/> />

View File

@@ -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)"
/> />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",

View File

@@ -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(),
], ],