This commit is contained in:
2026-02-05 14:44:54 +07:00
parent c3a8e5b474
commit 7f5bfc7a71
55 changed files with 1559 additions and 2591 deletions

661
bun.lock

File diff suppressed because it is too large Load Diff

52
components.d.ts vendored
View File

@@ -16,10 +16,12 @@ declare module 'vue' {
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
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']

View File

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

View File

@@ -1,3 +1,9 @@
<script setup lang="ts">
import Toast from './ui/form/Toast.vue';
</script>
<template>
<Toast />
<router-view/>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
interface AvatarProps {
label?: string;
shape?: 'circle' | 'square';
size?: 'small' | 'medium' | 'large';
}
const props = withDefaults(defineProps<AvatarProps>(), {
shape: 'circle',
size: 'medium',
});
const sizeClasses = {
small: 'w-8 h-8 text-xs',
medium: 'w-10 h-10 text-sm',
large: 'w-12 h-12 text-base',
};
const shapeClasses = {
circle: 'rounded-full',
square: 'rounded-lg',
};
</script>
<template>
<div
:class="[
'inline-flex items-center justify-center font-medium bg-gray-200 text-gray-600',
sizeClasses[size],
shapeClasses[shape],
]"
>
<slot>
{{ label?.charAt(0).toUpperCase() || '?' }}
</slot>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
interface ButtonProps {
type?: 'button' | 'submit' | 'reset';
variant?: 'primary' | 'secondary' | 'outlined' | 'text';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
label?: string;
}
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'button',
variant: 'primary',
size: 'medium',
disabled: false,
loading: false,
});
const variantClasses = {
primary: 'bg-primary text-white hover:opacity-90',
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
outlined: 'border border-gray-300 bg-transparent hover:bg-gray-50',
text: 'bg-transparent hover:bg-gray-100',
};
const sizeClasses = {
small: 'px-3 py-1.5 text-xs',
medium: 'px-4 py-2 text-sm',
large: 'px-6 py-3 text-base',
};
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="[
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
'focus:outline-none focus:ring-2 focus:ring-primary/20',
'disabled:opacity-50 disabled:cursor-not-allowed',
variantClasses[variant],
sizeClasses[size],
loading ? 'cursor-wait' : '',
]"
>
<svg
v-if="loading"
class="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<slot>{{ label }}</slot>
</button>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
interface CardProps {
cardClass?: string;
}
defineProps<CardProps>();
</script>
<template>
<div :class="['bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden', cardClass]">
<slot name="header" />
<div>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
interface CheckboxProps {
name: string;
value?: any;
binary?: boolean;
disabled?: boolean;
}
const props = withDefaults(defineProps<CheckboxProps>(), {
binary: false,
disabled: false,
});
const formContext = inject<{
values: Record<string, any>;
errors: Record<string, string>;
touched: Record<string, boolean>;
handleBlur: (name: string) => void;
handleChange: (name: string, value: any) => void;
} | null>('form-context', null);
const error = computed(() => formContext?.errors[props.name]);
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
const modelValue = computed({
get: () => {
const val = formContext?.values[props.name];
if (props.binary) return !!val;
return val?.includes(props.value);
},
set: (val) => {
if (props.binary) {
formContext?.handleChange(props.name, val);
} else {
const current = formContext?.values[props.name] || [];
if (val) {
formContext?.handleChange(props.name, [...current, props.value]);
} else {
formContext?.handleChange(props.name, current.filter((v: any) => v !== props.value));
}
}
},
});
</script>
<template>
<div class="flex items-center gap-2">
<input
:id="name + '-' + (value ?? 'binary')"
type="checkbox"
v-model="modelValue"
:disabled="disabled"
:class="[
'w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/20',
'disabled:opacity-50 disabled:cursor-not-allowed',
isInvalid ? 'border-red-500' : '',
]"
@blur="formContext?.handleBlur(name)"
/>
<label v-if="$slots.default" :for="name + '-' + (value ?? 'binary')" class="text-sm text-gray-700">
<slot />
</label>
</div>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
interface DialogProps {
visible: boolean;
header?: string;
style?: Record<string, string>;
closable?: boolean;
modal?: boolean;
}
const props = withDefaults(defineProps<DialogProps>(), {
closable: true,
modal: true,
});
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const handleClose = () => {
if (props.closable) {
emit('update:visible', false);
}
};
const handleBackdropClick = () => {
if (props.modal) {
handleClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.visible) {
handleClose();
}
};
onMounted(() => {
document.addEventListener('keydown', handleEscape);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape);
});
watch(() => props.visible, (val) => {
if (val) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
});
</script>
<template>
<Teleport to="body">
<Transition name="dialog">
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50"
@click="handleBackdropClick"
/>
<!-- Dialog -->
<div
class="relative bg-white rounded-xl shadow-xl w-full max-h-[90vh] overflow-auto"
:style="style || { width: '28rem' }"
>
<!-- Header -->
<div v-if="header" class="flex items-center justify-between px-6 py-4 border-b">
<h3 class="text-lg font-semibold text-gray-900">{{ header }}</h3>
<button
v-if="closable"
@click="handleClose"
class="p-1 rounded-lg hover:bg-gray-100 transition-colors"
>
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="p-6 pt-4">
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.2s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
.dialog-enter-active .relative,
.dialog-leave-active .relative {
transition: transform 0.2s ease;
}
.dialog-enter-from .relative,
.dialog-leave-to .relative {
transform: scale(0.95);
}
</style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
interface FieldProps {
name: string;
label?: string;
}
const props = defineProps<FieldProps>();
const formContext = inject<{
values: Record<string, any>;
errors: Record<string, string>;
touched: Record<string, boolean>;
validators: Record<string, ((value: any) => string | undefined)[]>;
handleBlur: (name: string) => void;
handleChange: (name: string, value: any) => void;
} | null>('form-context', null);
const error = computed(() => props.name ? formContext?.errors[props.name] : undefined);
const isInvalid = computed(() => props.name ? formContext?.touched[props.name] && !!error.value : false);
const fieldValue = computed(() => props.name ? formContext?.values[props.name] ?? '' : '');
const onChange = (value: any) => {
if (props.name && formContext) {
formContext.handleChange(props.name, value);
}
};
const onBlur = () => {
if (props.name && formContext) {
formContext.handleBlur(props.name);
}
};
// Provide values to slot
const slotProps = {
value: fieldValue,
error: error,
errorMessage: error,
isInvalid,
name: props.name,
onChange,
onBlur,
};
</script>
<template>
<div class="field flex flex-col gap-1">
<label v-if="label" :for="name" class="text-sm font-medium text-gray-700">
{{ label }}
</label>
<slot v-bind="slotProps" />
<div v-if="isInvalid && error" class="text-xs text-red-600 mt-1">
{{ error }}
</div>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { provide, reactive } from 'vue';
const props = defineProps<{
initialValues?: Record<string, any>;
validators?: Record<string, ((value: any) => string | undefined)[]>;
formClass?: string;
}>();
const emit = defineEmits<{
submit: [values: Record<string, any>];
}>();
const errors = reactive<Record<string, string>>({});
const touched = reactive<Record<string, boolean>>({});
const values = reactive<Record<string, any>>({...props.initialValues});
// Initialize values
if (props.initialValues) {
Object.assign(values, props.initialValues);
}
const validateField = (name: string) => {
const value = values[name];
const fieldValidators = props.validators?.[name] || [];
for (const validator of fieldValidators) {
const error = validator(value);
if (error) {
errors[name] = error;
return false;
}
}
delete errors[name];
return true;
};
const validateAll = () => {
const fieldNames = Object.keys(props.validators || {});
let isValid = true;
for (const name of fieldNames) {
if (!validateField(name)) {
isValid = false;
}
}
return isValid;
};
const handleSubmit = () => {
// Mark all fields as touched
const fieldNames = Object.keys(props.validators || {});
for (const name of fieldNames) {
touched[name] = true;
}
if (validateAll()) {
emit('submit', {...values});
}
};
const handleBlur = (name: string) => {
touched[name] = true;
validateField(name);
};
const handleChange = (name: string, value: any) => {
values[name] = value;
if (touched[name]) {
validateField(name);
}
};
// Provide form context to child components
provide('form-context', {
values,
errors,
touched,
validators: props.validators || {},
handleBlur,
handleChange,
validateField,
});
</script>
<template>
<form @submit.prevent="handleSubmit" :class="[formClass, 'flex flex-col gap-4 w-full']">
<slot />
</form>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue';
interface InputProps {
name?: string;
type?: string;
placeholder?: string;
disabled?: boolean;
fluid?: boolean;
modelValue?: string | any;
}
const props = withDefaults(defineProps<InputProps>(), {
type: 'text',
disabled: false,
fluid: true,
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
// Handle the v-model binding - support both string and computed ref
const inputValue = computed(() => {
const val = props.modelValue;
// Check if it's a ref/computed
if (val && typeof val === 'object' && 'value' in val) {
return val.value;
}
return val ?? '';
});
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.value);
};
</script>
<template>
<input
:id="name"
:value="inputValue"
@input="onInput"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="[
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors',
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
fluid ? 'w-full' : '',
'border-gray-300',
]"
/>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
interface ProgressBarProps {
value?: number;
showValue?: boolean;
}
const props = withDefaults(defineProps<ProgressBarProps>(), {
value: 0,
showValue: false,
});
</script>
<template>
<div class="w-full">
<div
v-if="showValue"
class="flex justify-between mb-1"
>
<span class="text-sm font-medium text-gray-700">
<slot name="value">{{ Math.round(value) }}%</slot>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="bg-primary h-2 rounded-full transition-all duration-300"
:style="{ width: `${Math.min(100, Math.max(0, value))}%` }"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
interface SkeletonProps {
width?: string;
height?: string;
borderRadius?: string;
}
const props = withDefaults(defineProps<SkeletonProps>(), {
width: '100%',
height: '1rem',
borderRadius: '0.375rem',
});
</script>
<template>
<div
class="animate-pulse bg-gray-200"
:style="{
width,
height,
borderRadius,
}"
/>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts" generic="T">
interface TableProps<T> {
value: T[];
dataKey: string;
selection?: T[];
tableStyle?: string;
}
const props = defineProps<TableProps<T>>();
const emit = defineEmits<{
'update:selection': [value: T[]];
}>();
</script>
<template>
<div class="overflow-x-auto">
<table :class="['w-full', tableStyle]">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
<slot name="header" />
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in value"
:key="String((item as any)[dataKey])"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<slot name="body" :data="item" :index="index" />
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
interface TagProps {
value?: string;
severity?: 'success' | 'error' | 'warn' | 'info' | 'secondary';
rounded?: boolean;
}
const props = withDefaults(defineProps<TagProps>(), {
rounded: true,
});
const severityClasses = {
success: 'bg-green-100 text-green-800',
error: 'bg-red-100 text-red-800',
warn: 'bg-yellow-100 text-yellow-800',
info: 'bg-blue-100 text-blue-800',
secondary: 'bg-gray-100 text-gray-800',
};
</script>
<template>
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 text-xs font-medium',
rounded ? 'rounded-full' : 'rounded',
severity ? severityClasses[severity] : 'bg-gray-100 text-gray-800',
]"
>
<slot>{{ value }}</slot>
</span>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts" generic="T">
import { useForm } from '@tanstack/vue-form';
import { provide } from 'vue';
interface FormProps {
initialValues?: T;
onSubmit?: (values: T) => void | Promise<void>;
validators?: Record<string, (value: any) => string | undefined>;
}
const props = defineProps<FormProps>();
const form = useForm({
initialValues: props.initialValues,
onSubmit: async (values) => {
await props.onSubmit?.(values.value);
},
});
// Provide form context to child components
provide('tanstack-form', form);
provide('tanstack-form-validators', props.validators || {});
const handleSubmit = (e: Event) => {
e.preventDefault();
form.handleSubmit();
};
</script>
<template>
<form @submit="handleSubmit" class="flex flex-col gap-4 w-full">
<slot :form="form" />
</form>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
interface TextareaProps {
name: string;
rows?: number;
placeholder?: string;
disabled?: boolean;
fluid?: boolean;
}
const props = withDefaults(defineProps<TextareaProps>(), {
rows: 3,
disabled: false,
fluid: true,
});
const formContext = inject<{
values: Record<string, any>;
errors: Record<string, string>;
touched: Record<string, boolean>;
handleBlur: (name: string) => void;
handleChange: (name: string, value: any) => void;
} | null>('form-context', null);
const error = computed(() => formContext?.errors[props.name]);
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
const modelValue = computed({
get: () => formContext?.values[props.name] ?? '',
set: (val) => formContext?.handleChange(props.name, val),
});
</script>
<template>
<textarea
:id="name"
v-model="modelValue"
:rows="rows"
:placeholder="placeholder"
:disabled="disabled"
:class="[
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors resize-none',
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
fluid ? 'w-full' : '',
isInvalid ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300',
]"
@blur="formContext?.handleBlur(name)"
/>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { provide, ref } from 'vue';
interface ToastItem {
id: string;
severity: 'success' | 'error' | 'warn' | 'info';
summary: string;
detail?: string;
life?: number;
}
const toasts = ref<ToastItem[]>([]);
const addToast = (toast: Omit<ToastItem, 'id'>) => {
const id = crypto.randomUUID();
const newToast = { ...toast, id };
toasts.value.push(newToast);
const life = toast.life || 5000;
setTimeout(() => {
removeToast(id);
}, life);
};
const removeToast = (id: string) => {
const index = toasts.value.findIndex(t => t.id === id);
if (index > -1) {
toasts.value.splice(index, 1);
}
};
const toast = {
add: addToast,
remove: removeToast,
};
provide('toast', toast);
const severityClasses = {
success: 'bg-green-50 text-green-800 border-green-200',
error: 'bg-red-50 text-red-800 border-red-200',
warn: 'bg-yellow-50 text-yellow-800 border-yellow-200',
info: 'bg-blue-50 text-blue-800 border-blue-200',
};
const severityIcons = {
success: '✓',
error: '✕',
warn: '⚠',
info: '',
};
</script>
<template>
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
severityClasses[toast.severity],
]"
>
<span class="text-lg">{{ severityIcons[toast.severity] }}</span>
<div class="flex-1 min-w-0">
<p class="font-medium">{{ toast.summary }}</p>
<p v-if="toast.detail" class="text-sm opacity-90 mt-1">{{ toast.detail }}</p>
</div>
<button
@click="removeToast(toast.id)"
class="p-1 hover:bg-black/5 rounded transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>

View File

@@ -0,0 +1,15 @@
export { default as Avatar } from './Avatar.vue';
export { default as Button } from './Button.vue';
export { default as Card } from './Card.vue';
export { default as Checkbox } from './Checkbox.vue';
export { default as Dialog } from './Dialog.vue';
export { default as Field } from './Field.vue';
export { default as Form } from './Form.vue';
export { default as Input } from './Input.vue';
export { default as ProgressBar } from './ProgressBar.vue';
export { default as Skeleton } from './Skeleton.vue';
export { default as Table } from './Table.vue';
export { default as Tag } from './Tag.vue';
export { default as Textarea } from './Textarea.vue';
export { default as Toast } from './Toast.vue';

View File

@@ -6,14 +6,10 @@ import { streamText } from 'hono/streaming';
import isMobile from 'is-mobile';
import { 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

View File

@@ -1,77 +0,0 @@
import SWRVCache, { type ICacheItem } from '..'
import type { IKey } from '../../types'
/**
* LocalStorage cache adapter for swrv data cache.
* https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
*/
export default class LocalStorageCache extends SWRVCache<any> {
private STORAGE_KEY
constructor (key = 'swrv', ttl = 0) {
super(ttl)
this.STORAGE_KEY = key
}
private encode (storage: any) { return JSON.stringify(storage) }
private decode (storage: any) { return JSON.parse(storage) }
get (k: IKey): ICacheItem<IKey> {
const item = localStorage.getItem(this.STORAGE_KEY)
if (item) {
const _key = this.serializeKey(k)
const itemParsed: ICacheItem<any> = JSON.parse(item)[_key]
if (itemParsed?.expiresAt === null) {
itemParsed.expiresAt = Infinity // localStorage sets Infinity to 'null'
}
return itemParsed
}
return undefined as any
}
set (k: string, v: any, ttl: number) {
let payload = {}
const _key = this.serializeKey(k)
const timeToLive = ttl || this.ttl
const storage = localStorage.getItem(this.STORAGE_KEY)
const now = Date.now()
const item = {
data: v,
createdAt: now,
expiresAt: timeToLive ? now + timeToLive : Infinity
}
if (storage) {
payload = this.decode(storage)
(payload as any)[_key] = item
} else {
payload = { [_key]: item }
}
this.dispatchExpire(timeToLive, item, _key)
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
}
dispatchExpire (ttl: number, item: any, serializedKey: string) {
ttl && setTimeout(() => {
const current = Date.now()
const hasExpired = current >= item.expiresAt
if (hasExpired) this.delete(serializedKey)
}, ttl)
}
delete (serializedKey: string) {
const storage = localStorage.getItem(this.STORAGE_KEY)
let payload = {} as Record<string, any>
if (storage) {
payload = this.decode(storage)
delete payload[serializedKey]
}
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
}
}

View File

@@ -1,72 +0,0 @@
import type { IKey } from '../types'
import hash from '../lib/hash'
export interface ICacheItem<Data> {
data: Data,
createdAt: number,
expiresAt: number
}
function serializeKeyDefault (key: IKey): string {
if (typeof key === 'function') {
try {
key = key()
} catch (err) {
// dependencies not ready
key = ''
}
}
if (Array.isArray(key)) {
key = hash(key)
} else {
// convert null to ''
key = String(key || '')
}
return key
}
export default class SWRVCache<CacheData> {
protected ttl: number
private items?: Map<string, ICacheItem<CacheData>>
constructor (ttl = 0) {
this.items = new Map()
this.ttl = ttl
}
serializeKey (key: IKey): string {
return serializeKeyDefault(key)
}
get (k: string): ICacheItem<CacheData> {
const _key = this.serializeKey(k)
return this.items!.get(_key)!
}
set (k: string, v: any, ttl: number) {
const _key = this.serializeKey(k)
const timeToLive = ttl || this.ttl
const now = Date.now()
const item = {
data: v,
createdAt: now,
expiresAt: timeToLive ? now + timeToLive : Infinity
}
this.dispatchExpire(timeToLive, item, _key)
this.items!.set(_key, item)
}
dispatchExpire (ttl: number, item: any, serializedKey: string) {
ttl && setTimeout(() => {
const current = Date.now()
const hasExpired = current >= item.expiresAt
if (hasExpired) this.delete(serializedKey)
}, ttl)
}
delete (serializedKey: string) {
this.items!.delete(serializedKey)
}
}

View File

@@ -1,8 +0,0 @@
import SWRVCache from './cache'
import useSWRV, { mutate } from './use-swrv'
export {
type IConfig
} from './types'
export { mutate, SWRVCache }
export default useSWRV

View File

@@ -1,44 +0,0 @@
// From https://github.com/vercel/swr/blob/master/src/libs/hash.ts
// use WeakMap to store the object->key mapping
// so the objects can be garbage collected.
// WeakMap uses a hashtable under the hood, so the lookup
// complexity is almost O(1).
const table = new WeakMap()
// counter of the key
let counter = 0
// hashes an array of objects and returns a string
export default function hash (args: any[]): string {
if (!args.length) return ''
let key = 'arg'
for (let i = 0; i < args.length; ++i) {
let _hash
if (
args[i] === null ||
(typeof args[i] !== 'object' && typeof args[i] !== 'function')
) {
// need to consider the case that args[i] is a string:
// args[i] _hash
// "undefined" -> '"undefined"'
// undefined -> 'undefined'
// 123 -> '123'
// null -> 'null'
// "null" -> '"null"'
if (typeof args[i] === 'string') {
_hash = '"' + args[i] + '"'
} else {
_hash = String(args[i])
}
} else {
if (!table.has(args[i])) {
_hash = counter
table.set(args[i], counter++)
} else {
_hash = table.get(args[i])
}
}
key += '@' + _hash
}
return key
}

View File

@@ -1,27 +0,0 @@
function isOnline (): boolean {
if (typeof navigator.onLine !== 'undefined') {
return navigator.onLine
}
// always assume it's online
return true
}
function isDocumentVisible (): boolean {
if (
typeof document !== 'undefined' &&
typeof document.visibilityState !== 'undefined'
) {
return document.visibilityState !== 'hidden'
}
// always assume it's visible
return true
}
const fetcher = (url: string | Request) => fetch(url).then(res => res.json())
export default {
isOnline,
isDocumentVisible,
fetcher
}

View File

@@ -1,42 +0,0 @@
import type { Ref, WatchSource } from 'vue'
import SWRVCache from './cache'
import LocalStorageCache from './cache/adapters/localStorage'
export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
export interface IConfig<
Data = any,
Fn extends fetcherFn<Data> = fetcherFn<Data>
> {
refreshInterval?: number
cache?: LocalStorageCache | SWRVCache<any>
dedupingInterval?: number
ttl?: number
serverTTL?: number
revalidateOnFocus?: boolean
revalidateDebounce?: number
shouldRetryOnError?: boolean
errorRetryInterval?: number
errorRetryCount?: number
fetcher?: Fn,
isOnline?: () => boolean
isDocumentVisible?: () => boolean
}
export interface revalidateOptions {
shouldRetryOnError?: boolean,
errorRetryCount?: number,
forceRevalidate?: boolean,
}
export interface IResponse<Data = any, Error = any> {
data: Ref<Data | undefined>
error: Ref<Error | undefined>
isValidating: Ref<boolean>
isLoading: Ref<boolean>
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => Promise<void>
}
export type keyType = string | any[] | null | undefined
export type IKey = keyType | WatchSource<keyType>

View File

@@ -1,470 +0,0 @@
/** ____
*--------------/ \.------------------/
* / swrv \. / //
* / / /\. / //
* / _____/ / \. /
* / / ____/ . \. /
* / \ \_____ \. /
* / . \_____ \ \ / //
* \ _____/ / ./ / //
* \ / _____/ ./ /
* \ / / . ./ /
* \ / / ./ /
* . \/ ./ / //
* \ ./ / //
* \.. / /
* . ||| /
* ||| /
* . ||| / //
* ||| / //
* ||| /
*/
import { tinyassert } from "@hiogawa/utils";
import {
getCurrentInstance,
inject,
isReadonly,
isRef,
// isRef,
onMounted,
onServerPrefetch,
onUnmounted,
reactive,
ref,
toRefs,
useSSRContext,
watch,
type FunctionPlugin
} from 'vue';
import SWRVCache from './cache';
import webPreset from './lib/web-preset';
import type { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types';
type StateRef<Data, Error> = {
data: Data, error: Error, isValidating: boolean, isLoading: boolean, revalidate: Function, key: any
};
const DATA_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
const REF_CACHE = new SWRVCache<StateRef<any, any>[]>()
const PROMISES_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
const defaultConfig: IConfig = {
cache: DATA_CACHE,
refreshInterval: 0,
ttl: 0,
serverTTL: 1000,
dedupingInterval: 2000,
revalidateOnFocus: true,
revalidateDebounce: 0,
shouldRetryOnError: true,
errorRetryInterval: 5000,
errorRetryCount: 5,
fetcher: webPreset.fetcher,
isOnline: webPreset.isOnline,
isDocumentVisible: webPreset.isDocumentVisible
}
/**
* Cache the refs for later revalidation
*/
function setRefCache(key: string, theRef: StateRef<any, any>, ttl: number) {
const refCacheItem = REF_CACHE.get(key)
if (refCacheItem) {
refCacheItem.data.push(theRef)
} else {
// #51 ensures ref cache does not evict too soon
const gracePeriod = 5000
REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl)
}
}
function onErrorRetry(revalidate: (any: any, opts: revalidateOptions) => void, errorRetryCount: number, config: IConfig): void {
if (!(config as any).isDocumentVisible()) {
return
}
if (config.errorRetryCount !== undefined && errorRetryCount > config.errorRetryCount) {
return
}
const count = Math.min(errorRetryCount || 0, (config as any).errorRetryCount)
const timeout = count * (config as any).errorRetryInterval
setTimeout(() => {
revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true })
}, timeout)
}
/**
* Main mutation function for receiving data from promises to change state and
* set data cache
*/
const mutate = async <Data>(key: string, res: Promise<Data> | Data, cache = DATA_CACHE, ttl = defaultConfig.ttl) => {
let data, error, isValidating
if (isPromise(res)) {
try {
data = await res
} catch (err) {
error = err
}
} else {
data = res
}
// eslint-disable-next-line prefer-const
isValidating = false
const newData = { data, error, isValidating }
if (typeof data !== 'undefined') {
try {
cache.set(key, newData, Number(ttl))
} catch (err) {
console.error('swrv(mutate): failed to set cache', err)
}
}
/**
* Revalidate all swrv instances with new data
*/
const stateRef = REF_CACHE.get(key)
if (stateRef && stateRef.data.length) {
// This filter fixes #24 race conditions to only update ref data of current
// key, while data cache will continue to be updated if revalidation is
// fired
let refs = stateRef.data.filter(r => r.key === key)
refs.forEach((r, idx) => {
if (typeof newData.data !== 'undefined') {
r.data = newData.data
}
r.error = newData.error
r.isValidating = newData.isValidating
r.isLoading = newData.isValidating
const isLast = idx === refs.length - 1
if (!isLast) {
// Clean up refs that belonged to old keys
delete refs[idx]
}
})
refs = refs.filter(Boolean)
}
return newData
}
/* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
function useSWRV<Data = any, Error = any>(
key: IKey
): IResponse<Data, Error>
function useSWRV<Data = any, Error = any>(
key: IKey,
fn: fetcherFn<Data> | undefined | null,
config?: IConfig
): IResponse<Data, Error>
function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error> {
const injectedConfig = inject<Partial<IConfig> | null>('swrv-config', null)
tinyassert(injectedConfig, 'Injected swrv-config must be an object')
let key: IKey
let fn: fetcherFn<Data> | undefined | null
let config: IConfig = { ...defaultConfig, ...injectedConfig }
let unmounted = false
let isHydrated = false
const instance = getCurrentInstance() as any
const vm = instance?.proxy || instance // https://github.com/vuejs/composition-api/pull/520
if (!vm) {
console.error('Could not get current instance, check to make sure that `useSwrv` is declared in the top level of the setup function.')
throw new Error('Could not get current instance')
}
const IS_SERVER = typeof window === 'undefined' || false
// #region ssr
const isSsrHydration = Boolean(
!IS_SERVER &&
window !== undefined && (window as any).window.swrv)
// #endregion
if (args.length >= 1) {
key = args[0]
}
if (args.length >= 2) {
fn = args[1]
}
if (args.length > 2) {
config = {
...config,
...args[2]
}
}
const ttl = IS_SERVER ? config.serverTTL : config.ttl
const keyRef = typeof key === 'function' ? (key as any) : ref(key)
if (typeof fn === 'undefined') {
// use the global fetcher
fn = config.fetcher
}
let stateRef: StateRef<Data, Error> | null = null
// #region ssr
if (isSsrHydration) {
// component was ssrHydrated, so make the ssr reactive as the initial data
const swrvState = (window as any).window.swrv || []
const swrvKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
if (swrvKey !== undefined && swrvKey !== null) {
const nodeState = swrvState[swrvKey] || []
const instanceState = nodeState[nanoHex(isRef(keyRef) ? keyRef.value : keyRef())]
if (instanceState) {
stateRef = reactive(instanceState)
isHydrated = true
}
}
}
// #endregion
if (!stateRef) {
stateRef = reactive({
data: undefined,
error: undefined,
isValidating: true,
isLoading: true,
key: null
}) as StateRef<Data, Error>
}
/**
* Revalidate the cache, mutate data
*/
const revalidate = async (data?: fetcherFn<Data>, opts?: revalidateOptions) => {
const isFirstFetch = stateRef.data === undefined
const keyVal = keyRef.value
if (!keyVal) { return }
const cacheItem = config.cache!.get(keyVal)
const newData = cacheItem && cacheItem.data
stateRef.isValidating = true
stateRef.isLoading = !newData
if (newData) {
stateRef.data = newData.data
stateRef.error = newData.error
}
const fetcher = data || fn
if (
!fetcher ||
(!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) ||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
) {
stateRef.isValidating = false
stateRef.isLoading = false
return
}
// Dedupe items that were created in the last interval #76
if (cacheItem) {
const shouldRevalidate = Boolean(
((Date.now() - cacheItem.createdAt) >= (config as any).dedupingInterval) || opts?.forceRevalidate
)
if (!shouldRevalidate) {
stateRef.isValidating = false
stateRef.isLoading = false
return
}
}
const trigger = async () => {
const promiseFromCache = PROMISES_CACHE.get(keyVal)
if (!promiseFromCache) {
const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal]
const newPromise = fetcher(...fetcherArgs)
PROMISES_CACHE.set(keyVal, newPromise, (config as any).dedupingInterval)
await mutate(keyVal, newPromise, (config as any).cache, ttl)
} else {
await mutate(keyVal, promiseFromCache.data, (config as any).cache, ttl)
}
stateRef.isValidating = false
stateRef.isLoading = false
PROMISES_CACHE.delete(keyVal)
if (stateRef.error !== undefined) {
const shouldRetryOnError = !unmounted && config.shouldRetryOnError && (opts ? opts.shouldRetryOnError : true)
if (shouldRetryOnError) {
onErrorRetry(revalidate, opts ? Number(opts.errorRetryCount) : 1, config)
}
}
}
if (newData && config.revalidateDebounce) {
setTimeout(async () => {
if (!unmounted) {
await trigger()
}
}, config.revalidateDebounce)
} else {
await trigger()
}
}
const revalidateCall = async () => revalidate(null as any, { shouldRetryOnError: false })
let timer: any = null
/**
* Setup polling
*/
onMounted(() => {
const tick = async () => {
// component might un-mount during revalidate, so do not set a new timeout
// if this is the case, but continue to revalidate since promises can't
// be cancelled and new hook instances might rely on promise/data cache or
// from pre-fetch
if (!stateRef.error && (config as any).isOnline()) {
// if API request errored, we stop polling in this round
// and let the error retry function handle it
await revalidate()
} else {
if (timer) {
clearTimeout(timer)
}
}
if (config.refreshInterval && !unmounted) {
timer = setTimeout(tick, config.refreshInterval)
}
}
if (config.refreshInterval) {
timer = setTimeout(tick, config.refreshInterval)
}
if (config.revalidateOnFocus) {
document.addEventListener('visibilitychange', revalidateCall, false)
window.addEventListener('focus', revalidateCall, false)
}
})
/**
* Teardown
*/
onUnmounted(() => {
unmounted = true
if (timer) {
clearTimeout(timer)
}
if (config.revalidateOnFocus) {
document.removeEventListener('visibilitychange', revalidateCall, false)
window.removeEventListener('focus', revalidateCall, false)
}
const refCacheItem = REF_CACHE.get(keyRef.value)
if (refCacheItem) {
refCacheItem.data = refCacheItem.data.filter((ref) => ref !== stateRef)
}
})
// #region ssr
if (IS_SERVER) {
const ssrContext = useSSRContext()
// make sure srwv exists in ssrContext
let swrvRes: Record<string, any> = {}
if (ssrContext) {
swrvRes = ssrContext.swrv = ssrContext.swrv || swrvRes
}
const ssrKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
// if (!vm.$vnode || (vm.$node && !vm.$node.data)) {
// vm.$vnode = {
// data: { attrs: { 'data-swrv-key': ssrKey } }
// }
// }
// const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {})
// attrs['data-swrv-key'] = ssrKey
// // Nuxt compatibility
// if (vm.$ssrContext && vm.$ssrContext.nuxt) {
// vm.$ssrContext.nuxt.swrv = swrvRes
// }
if (ssrContext) {
ssrContext.swrv = swrvRes
}
onServerPrefetch(async () => {
await revalidate()
if (!swrvRes[ssrKey]) swrvRes[ssrKey] = {}
swrvRes[ssrKey][nanoHex(keyRef.value)] = {
data: stateRef.data,
error: stateRef.error,
isValidating: stateRef.isValidating
}
})
}
// #endregion
/**
* Revalidate when key dependencies change
*/
try {
watch(keyRef, (val) => {
if (!isReadonly(keyRef)) {
keyRef.value = val
}
stateRef.key = val
stateRef.isValidating = Boolean(val)
setRefCache(keyRef.value, stateRef, Number(ttl))
if (!IS_SERVER && !isHydrated && keyRef.value) {
revalidate()
}
isHydrated = false
}, {
immediate: true
})
} catch {
// do nothing
}
const res: IResponse = {
...toRefs(stateRef),
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => revalidate(data, {
...opts,
forceRevalidate: true
})
}
return res
}
function isPromise<T>(p: any): p is Promise<T> {
return p !== null && typeof p === 'object' && typeof p.then === 'function'
}
/**
* string to hex 8 chars
* @param name string
* @returns string
*/
function nanoHex(name: string): string {
try {
let hash = 0
for (let i = 0; i < name.length; i++) {
const chr = name.charCodeAt(i)
hash = ((hash << 5) - hash) + chr
hash |= 0 // Convert to 32bit integer
}
let hex = (hash >>> 0).toString(16)
while (hex.length < 8) {
hex = '0' + hex
}
return hex
} catch {
console.error("err name: ", name)
return '0000'
}
}
export const vueSWR = (swrvConfig: Partial<IConfig> = defaultConfig): FunctionPlugin => (app) => {
app.config.globalProperties.$swrv = useSWRV
// app.provide('swrv', useSWRV)
app.provide('swrv-config', swrvConfig)
}
export { mutate };
export default useSWRV

View File

@@ -1,16 +1,10 @@
import { createHead as CSRHead } from "@unhead/vue/client";
import { createHead as 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);

View File

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

View File

@@ -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 validators = {
email: [
(value: string) => !value ? 'Email is required.' : undefined,
(value: string) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email address.' : undefined,
],
};
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
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 });
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 });
.catch((error: any) => {
toast?.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
});
// forgotPassword(values.email).then(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// }).catch(() => {
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
// });
}
};
</script>

View File

@@ -23,6 +23,7 @@
</div>
</template>
<script setup lang="ts">
import vueHead from "@/components/VueHead";
import { useRoute } from 'vue-router';
const route = useRoute();

View File

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

View File

@@ -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%' }" />
<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>
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error?.message }}</Message>
</div>
<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 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,
],
};
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
const onFormSubmit = (values: Record<string, any>) => {
auth.register(values.name, values.email, values.password);
}
};
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
<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>
</InputIcon>
<InputText id="email" :value="user?.email" class="w-full pl-10" readonly />
</IconField>
</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 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>

View File

@@ -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,18 +15,20 @@ 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) }">
<template #header>
<div
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
<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)" />
<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"
@@ -47,10 +46,8 @@ const emit = defineEmits<{
{{ formatDuration(video.duration) }}
</span>
</div>
</template>
<template #content>
<div class="flex flex-col h-full">
<div class="p-4 flex flex-col h-full">
<div class="flex items-start justify-between gap-2 mb-1">
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
:title="video.title">
@@ -75,7 +72,6 @@ const emit = defineEmits<{
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>

View File

@@ -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,57 +15,56 @@ 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="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="data.thumbnail" :src="data.thumbnail" :alt="data.title"
<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">{{ data.title }}</p>
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
<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>
</template>
</Column>
<Column header="Status">
<template #body="{ data }">
</td>
<td class="px-4 py-3">
<span
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(data.status)]">
{{ data.status || 'Unknown' }}
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
{{ video.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 }">
</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"
@@ -86,14 +82,16 @@ const emit = defineEmits<{
title="Edit">
<span class="i-heroicons-pencil w-4 h-4" />
</button>
<button @click="emit('delete', data.id)"
<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>
</template>
</Column>
</DataTable>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -178,10 +178,6 @@ export default defineConfig({
DEFAULT: "#fafafa",
light: "#f8f9fa",
},
muted: {
DEFAULT: "#f5f4f2",
light: "#f8f9fa",
},
border: {
DEFAULT: "#e6e7e2",
light: "#f8f9fa",

View File

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