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