refactor: replace PrimeVue components with custom App components for buttons, dialogs, and inputs
- Updated DangerZone.vue to use AppButton and AppDialog, replacing PrimeVue Button and Dialog components. - Refactored DomainsDns.vue to utilize AppButton, AppDialog, and AppInput, enhancing the UI consistency. - Modified NotificationSettings.vue and PlayerSettings.vue to implement AppButton and AppSwitch for better styling. - Replaced PrimeVue components in SecurityNConnected.vue with AppButton, AppDialog, and AppInput for a cohesive design. - Introduced AppConfirmHost for handling confirmation dialogs with a custom design. - Created AppToastHost for managing toast notifications with custom styling and behavior. - Added utility composables useAppConfirm and useAppToast for managing confirmation dialogs and toast notifications. - Implemented AppProgressBar and AppSwitch components for improved UI elements.
This commit is contained in:
28
components.d.ts
vendored
28
components.d.ts
vendored
@@ -17,6 +17,13 @@ declare module 'vue' {
|
||||
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
AppButton: typeof import('./src/components/app/AppButton.vue')['default']
|
||||
AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
|
||||
AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
|
||||
AppInput: typeof import('./src/components/app/AppInput.vue')['default']
|
||||
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
@@ -44,18 +51,13 @@ declare module 'vue' {
|
||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
|
||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
IconField: typeof import('primevue/iconfield')['default']
|
||||
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
InputIcon: typeof import('primevue/inputicon')['default']
|
||||
InputNumber: typeof import('primevue/inputnumber')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
KeyIcon: typeof import('./src/components/icons/KeyIcon.vue')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||
LogOutIcon: typeof import('./src/components/icons/LogOutIcon.vue')['default']
|
||||
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
@@ -74,14 +76,12 @@ declare module 'vue' {
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
ShieldCheckIcon: typeof import('./src/components/icons/ShieldCheckIcon.vue')['default']
|
||||
Skeleton: typeof import('primevue/skeleton')['default']
|
||||
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
Tag: typeof import('primevue/tag')['default']
|
||||
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
ToggleSwitch: typeof import('primevue/toggleswitch')['default']
|
||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
@@ -105,6 +105,13 @@ declare global {
|
||||
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
const AppButton: typeof import('./src/components/app/AppButton.vue')['default']
|
||||
const AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
|
||||
const AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
|
||||
const AppInput: typeof import('./src/components/app/AppInput.vue')['default']
|
||||
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
@@ -132,18 +139,13 @@ declare global {
|
||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
|
||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
const IconField: typeof import('primevue/iconfield')['default']
|
||||
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
const InputIcon: typeof import('primevue/inputicon')['default']
|
||||
const InputNumber: typeof import('primevue/inputnumber')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const KeyIcon: typeof import('./src/components/icons/KeyIcon.vue')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||
const LogOutIcon: typeof import('./src/components/icons/LogOutIcon.vue')['default']
|
||||
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
const Message: typeof import('primevue/message')['default']
|
||||
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
@@ -162,14 +164,12 @@ declare global {
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
const ShieldCheckIcon: typeof import('./src/components/icons/ShieldCheckIcon.vue')['default']
|
||||
const Skeleton: typeof import('primevue/skeleton')['default']
|
||||
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
const Tag: typeof import('primevue/tag')['default']
|
||||
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
const ToggleSwitch: typeof import('primevue/toggleswitch')['default']
|
||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
|
||||
66
src/components/app/AppButton.vue
Normal file
66
src/components/app/AppButton.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all press-animated select-none';
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'px-3 py-1.5 text-sm';
|
||||
case 'md':
|
||||
default:
|
||||
return 'px-4 py-2 text-sm';
|
||||
}
|
||||
});
|
||||
|
||||
const variantClass = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'secondary':
|
||||
return 'bg-muted/50 text-foreground hover:bg-muted border border-border';
|
||||
case 'danger':
|
||||
return 'bg-danger text-white hover:bg-danger/90';
|
||||
case 'ghost':
|
||||
return 'bg-transparent text-foreground/70 hover:text-foreground hover:bg-muted/50';
|
||||
case 'primary':
|
||||
default:
|
||||
return 'bg-primary text-white hover:bg-primary/90';
|
||||
}
|
||||
});
|
||||
|
||||
const disabledClass = computed(() => (props.disabled || props.loading) ? 'opacity-60 cursor-not-allowed' : '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="cn(baseClass, sizeClass, variantClass, disabledClass)"
|
||||
>
|
||||
<span v-if="loading" class="inline-flex items-center" aria-hidden="true">
|
||||
<svg class="w-4 h-4 animate-spin" 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 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
|
||||
</svg>
|
||||
</span>
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
47
src/components/app/AppConfirmHost.vue
Normal file
47
src/components/app/AppConfirmHost.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
|
||||
const confirm = useAppConfirm();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="confirm.visible.value"
|
||||
@update:visible="(v) => !v && confirm.close()"
|
||||
:title="confirm.header.value"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-md bg-warning/10 flex items-center justify-center shrink-0">
|
||||
<AlertTriangleIcon class="w-5 h-5 text-warning" />
|
||||
</div>
|
||||
<p class="text-sm text-foreground/80 leading-relaxed">
|
||||
{{ confirm.message.value }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="confirm.loading.value"
|
||||
@click="confirm.reject"
|
||||
>
|
||||
{{ confirm.rejectLabel.value }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:loading="confirm.loading.value"
|
||||
@click="confirm.accept"
|
||||
>
|
||||
{{ confirm.acceptLabel.value }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
97
src/components/app/AppDialog.vue
Normal file
97
src/components/app/AppDialog.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
closable?: boolean;
|
||||
maxWidthClass?: string;
|
||||
}>(), {
|
||||
title: '',
|
||||
closable: true,
|
||||
maxWidthClass: 'max-w-lg',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const close = () => {
|
||||
emit('update:visible', false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (!props.visible) return;
|
||||
if (!props.closable) return;
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (v) window.addEventListener('keydown', onKeydown);
|
||||
else window.removeEventListener('keydown', onKeydown);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="visible" class="fixed inset-0 z-[9999]">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/30"
|
||||
@click="closable && close()"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div :class="cn('w-full bg-surface border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
|
||||
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="p-1 rounded-md text-foreground/60 hover:text-foreground hover:bg-muted/50 transition-all"
|
||||
@click="close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" class="px-5 py-4 border-t border-border bg-muted/20">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
91
src/components/app/AppInput.vue
Normal file
91
src/components/app/AppInput.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Vue macro is available at compile time; provide a safe fallback for typecheck.
|
||||
declare const defineModelModifiers: undefined | (<T>() => T);
|
||||
|
||||
|
||||
type Props = {
|
||||
modelValue?: string | number | null;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
autocomplete?: string;
|
||||
inputClass?: string;
|
||||
wrapperClass?: string;
|
||||
min?: number | string;
|
||||
max?: number | string;
|
||||
step?: number | string;
|
||||
maxlength?: number;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | null): void;
|
||||
(e: 'enter'): void;
|
||||
}>();
|
||||
|
||||
const modelModifiers = (typeof defineModelModifiers === 'function'
|
||||
? defineModelModifiers<{ number?: boolean }>()
|
||||
: ({} as { number?: boolean }));
|
||||
|
||||
const isNumberLike = computed(() => props.type === 'number' || !!modelModifiers.number);
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const raw = el.value;
|
||||
if (isNumberLike.value) {
|
||||
if (raw === '') {
|
||||
emit('update:modelValue', null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
emit('update:modelValue', Number.isNaN(n) ? null : n);
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', raw);
|
||||
};
|
||||
|
||||
const onKeyup = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') emit('enter');
|
||||
};
|
||||
|
||||
const baseInputClass = 'w-full px-3 py-2 rounded-md border border-border bg-surface text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 disabled:opacity-60 disabled:cursor-not-allowed';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('relative', wrapperClass)">
|
||||
<div v-if="$slots.prefix" class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/50">
|
||||
<slot name="prefix" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
:name="name"
|
||||
:type="type"
|
||||
:value="modelValue ?? ''"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:maxlength="maxlength"
|
||||
:class="cn(baseInputClass, $slots.prefix ? 'pl-10' : '', inputClass)"
|
||||
@input="onInput"
|
||||
@keyup="onKeyup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/app/AppProgressBar.vue
Normal file
21
src/components/app/AppProgressBar.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
value: number;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
const pct = computed(() => {
|
||||
const v = Number(props.value);
|
||||
if (Number.isNaN(v)) return 0;
|
||||
return Math.min(Math.max(v, 0), 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('w-full bg-muted/50 rounded-full overflow-hidden', props.class)" style="height: 6px">
|
||||
<div class="bg-primary h-full rounded-full transition-all duration-300" :style="{ width: `${pct}%` }" />
|
||||
</div>
|
||||
</template>
|
||||
46
src/components/app/AppSwitch.vue
Normal file
46
src/components/app/AppSwitch.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'change', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return;
|
||||
const next = !props.modelValue;
|
||||
emit('update:modelValue', next);
|
||||
emit('change', next);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
:class="cn(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
modelValue ? 'bg-primary' : 'bg-border'
|
||||
)"
|
||||
>
|
||||
<span
|
||||
:class="cn(
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform',
|
||||
modelValue ? 'translate-x-5' : 'translate-x-1'
|
||||
)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
101
src/components/app/AppToastHost.vue
Normal file
101
src/components/app/AppToastHost.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
|
||||
import InfoIcon from '@/components/icons/InfoIcon.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, watchEffect } from 'vue';
|
||||
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
|
||||
|
||||
const { toasts, remove } = useAppToast();
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
remove(id);
|
||||
};
|
||||
|
||||
const iconFor = (severity: AppToastSeverity) => {
|
||||
switch (severity) {
|
||||
case 'success':
|
||||
return CheckCircleIcon;
|
||||
case 'warn':
|
||||
return AlertTriangleIcon;
|
||||
case 'error':
|
||||
return XCircleIcon;
|
||||
case 'info':
|
||||
default:
|
||||
return InfoIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const toneClass = (severity: AppToastSeverity) => {
|
||||
switch (severity) {
|
||||
case 'success':
|
||||
return 'border-success/25 bg-success/5';
|
||||
case 'warn':
|
||||
return 'border-warning/25 bg-warning/5';
|
||||
case 'error':
|
||||
return 'border-danger/25 bg-danger/5';
|
||||
case 'info':
|
||||
default:
|
||||
return 'border-info/25 bg-info/5';
|
||||
}
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
for (const t of toasts.value) {
|
||||
if (timers.has(t.id)) continue;
|
||||
const life = Math.max(0, t.life ?? 3000);
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(t.id);
|
||||
}, life);
|
||||
timers.set(t.id, timer);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 w-[360px] max-w-[calc(100vw-2rem)]">
|
||||
<TransitionGroup
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
:class="cn('flex items-start gap-3 p-3 rounded-lg border shadow-sm', toneClass(t.severity))"
|
||||
>
|
||||
<component :is="iconFor(t.severity)" class="w-5 h-5 mt-0.5" :class="t.severity === 'success' ? 'text-success' : t.severity === 'warn' ? 'text-warning' : t.severity === 'error' ? 'text-danger' : 'text-info'" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground truncate">{{ t.summary }}</p>
|
||||
<p v-if="t.detail" class="text-xs text-foreground/70 mt-0.5 break-words">{{ t.detail }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
|
||||
@click="dismiss(t.id)"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
86
src/composables/useAppConfirm.ts
Normal file
86
src/composables/useAppConfirm.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { computed, reactive, readonly } from 'vue';
|
||||
|
||||
export type AppConfirmOptions = {
|
||||
message: string;
|
||||
header?: string;
|
||||
acceptLabel?: string;
|
||||
rejectLabel?: string;
|
||||
accept?: () => void | Promise<void>;
|
||||
reject?: () => void;
|
||||
};
|
||||
|
||||
type AppConfirmState = {
|
||||
visible: boolean;
|
||||
loading: boolean;
|
||||
message: string;
|
||||
header: string;
|
||||
acceptLabel: string;
|
||||
rejectLabel: string;
|
||||
accept?: () => void | Promise<void>;
|
||||
reject?: () => void;
|
||||
};
|
||||
|
||||
const state = reactive<AppConfirmState>({
|
||||
visible: false,
|
||||
loading: false,
|
||||
message: '',
|
||||
header: 'Confirm',
|
||||
acceptLabel: 'OK',
|
||||
rejectLabel: 'Cancel',
|
||||
});
|
||||
|
||||
const requireConfirm = (options: AppConfirmOptions) => {
|
||||
state.visible = true;
|
||||
state.loading = false;
|
||||
state.message = options.message;
|
||||
state.header = options.header ?? 'Confirm';
|
||||
state.acceptLabel = options.acceptLabel ?? 'OK';
|
||||
state.rejectLabel = options.rejectLabel ?? 'Cancel';
|
||||
state.accept = options.accept;
|
||||
state.reject = options.reject;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
state.visible = false;
|
||||
state.loading = false;
|
||||
state.message = '';
|
||||
state.accept = undefined;
|
||||
state.reject = undefined;
|
||||
};
|
||||
|
||||
const onReject = () => {
|
||||
try {
|
||||
state.reject?.();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onAccept = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
await state.accept?.();
|
||||
close();
|
||||
} catch (e) {
|
||||
// Keep dialog open on error; caller can show a toast.
|
||||
throw e;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAppConfirm = () => {
|
||||
return {
|
||||
require: requireConfirm,
|
||||
close,
|
||||
accept: onAccept,
|
||||
reject: onReject,
|
||||
visible: computed(() => state.visible),
|
||||
loading: computed(() => state.loading),
|
||||
message: computed(() => state.message),
|
||||
header: computed(() => state.header),
|
||||
acceptLabel: computed(() => state.acceptLabel),
|
||||
rejectLabel: computed(() => state.rejectLabel),
|
||||
_state: readonly(state),
|
||||
};
|
||||
};
|
||||
64
src/composables/useAppToast.ts
Normal file
64
src/composables/useAppToast.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { computed, reactive, readonly } from 'vue';
|
||||
|
||||
export type AppToastSeverity = 'success' | 'info' | 'warn' | 'warning' | 'error' | 'danger';
|
||||
|
||||
export type AppToastInput = {
|
||||
severity?: AppToastSeverity;
|
||||
summary?: string;
|
||||
detail?: string;
|
||||
life?: number; // ms
|
||||
};
|
||||
|
||||
export type AppToast = {
|
||||
id: string;
|
||||
severity: AppToastSeverity;
|
||||
summary: string;
|
||||
detail?: string;
|
||||
createdAt: number;
|
||||
life: number;
|
||||
};
|
||||
|
||||
const state = reactive<{ toasts: AppToast[] }>({
|
||||
toasts: [],
|
||||
});
|
||||
|
||||
const normalizeSeverity = (severity?: AppToastSeverity): AppToastSeverity => {
|
||||
if (!severity) return 'info';
|
||||
if (severity === 'warning') return 'warn';
|
||||
if (severity === 'danger') return 'error';
|
||||
return severity;
|
||||
};
|
||||
|
||||
const genId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const add = (input: AppToastInput) => {
|
||||
const toast: AppToast = {
|
||||
id: genId(),
|
||||
severity: normalizeSeverity(input.severity),
|
||||
summary: input.summary ?? '',
|
||||
detail: input.detail,
|
||||
createdAt: Date.now(),
|
||||
life: typeof input.life === 'number' ? input.life : 3000,
|
||||
};
|
||||
state.toasts.push(toast);
|
||||
return toast.id;
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
const idx = state.toasts.findIndex(t => t.id === id);
|
||||
if (idx !== -1) state.toasts.splice(idx, 1);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
state.toasts.splice(0, state.toasts.length);
|
||||
};
|
||||
|
||||
export const useAppToast = () => {
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
toasts: computed(() => state.toasts),
|
||||
_state: readonly(state),
|
||||
};
|
||||
};
|
||||
@@ -49,6 +49,12 @@
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<router-view />
|
||||
|
||||
<!-- Settings-only toast/confirm hosts (no PrimeVue dependency) -->
|
||||
<ClientOnly>
|
||||
<AppToastHost />
|
||||
<AppConfirmHost />
|
||||
</ClientOnly>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,6 +65,9 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import AppConfirmHost from '@/components/app/AppConfirmHost.vue';
|
||||
import AppToastHost from '@/components/app/AppToastHost.vue';
|
||||
import ClientOnly from '@/components/ClientOnly';
|
||||
import UserIcon from '@/components/icons/UserIcon.vue';
|
||||
import GlobeIcon from '@/components/icons/Globe.vue';
|
||||
import AlertTriangle from '@/components/icons/AlertTriangle.vue';
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LockIcon from '@/components/icons/LockIcon.vue';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||
|
||||
const toast = useToast();
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
dialogVisible: boolean;
|
||||
@@ -82,32 +78,30 @@ const handleChangePassword = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
<AppButton
|
||||
v-if="telegramConnected"
|
||||
label="Disconnect"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="$emit('disconnect-telegram')"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
>
|
||||
Disconnect
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-else
|
||||
label="Connect"
|
||||
size="small"
|
||||
size="sm"
|
||||
@click="$emit('connect-telegram')"
|
||||
class="press-animated"
|
||||
/>
|
||||
>
|
||||
Connect
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<Dialog
|
||||
<AppDialog
|
||||
:visible="dialogVisible"
|
||||
@update:visible="$emit('update:dialogVisible', $event)"
|
||||
modal
|
||||
header="Change Password"
|
||||
:style="{ width: '26rem' }"
|
||||
title="Change Password"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
@@ -122,75 +116,76 @@ const handleChangePassword = () => {
|
||||
<!-- Current Password -->
|
||||
<div class="grid gap-2">
|
||||
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="currentPassword"
|
||||
:model-value="currentPassword"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
class="w-full"
|
||||
@update:model-value="$emit('update:currentPassword', $event)"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div class="grid gap-2">
|
||||
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="newPassword"
|
||||
:model-value="newPassword"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
class="w-full"
|
||||
@update:model-value="$emit('update:newPassword', $event)"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="grid gap-2">
|
||||
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="confirmPassword"
|
||||
:model-value="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
class="w-full"
|
||||
@update:model-value="$emit('update:confirmPassword', $event)"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
label="Cancel"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="$emit('close')"
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Change Password"
|
||||
@click="handleChangePassword"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="loading"
|
||||
class="press-animated"
|
||||
/>
|
||||
@click="handleChangePassword"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Change Password
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import Button from 'primevue/button';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppProgressBar from '@/components/app/AppProgressBar.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import MailIcon from '@/components/icons/MailIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import UserIcon from '@/components/icons/UserIcon.vue';
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
const props = defineProps<{
|
||||
editing: boolean;
|
||||
@@ -71,36 +71,31 @@ const formatBytes = (bytes: number) => {
|
||||
<div class="grid gap-6 max-w-2xl">
|
||||
<div class="grid gap-2">
|
||||
<label for="username" class="text-sm font-medium text-foreground">Username</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<UserIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="username"
|
||||
:model-value="username"
|
||||
:readonly="!editing"
|
||||
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
|
||||
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
|
||||
@update:model-value="emit('update:username', String($event))"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<UserIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<label for="email" class="text-sm font-medium text-foreground">Email Address</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="email"
|
||||
:model-value="email"
|
||||
:readonly="!editing"
|
||||
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
|
||||
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
|
||||
@update:model-value="emit('update:email', $event || '')"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<MailIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,45 +115,36 @@ const formatBytes = (bytes: number) => {
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px" />
|
||||
<AppProgressBar :value="storagePercentage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3">
|
||||
<template v-if="editing">
|
||||
<Button
|
||||
label="Save Changes"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
@click="emit('save')"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Cancel"
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="emit('cancel-edit')"
|
||||
:disabled="saving"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton size="sm" :loading="saving" @click="emit('save')">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Save Changes
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('cancel-edit')">
|
||||
<template #icon>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Cancel
|
||||
</AppButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
label="Edit Profile"
|
||||
size="small"
|
||||
@click="emit('start-edit')"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Change Password"
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="emit('change-password')"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton size="sm" @click="emit('start-edit')">
|
||||
<template #icon>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Edit Profile
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" size="sm" @click="emit('change-password')">
|
||||
Change Password
|
||||
</AppButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import { ref } from 'vue';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LockIcon from '@/components/icons/LockIcon.vue';
|
||||
|
||||
const toast = useToast();
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
twoFactorEnabled: boolean;
|
||||
@@ -49,17 +46,8 @@ const confirmTwoFactor = async () => {
|
||||
twoFactorDialogVisible.value = false;
|
||||
twoFactorCode.value = '';
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
label: "Account Status",
|
||||
description: "Your account is in good standing",
|
||||
action: h(ToggleSwitch, {
|
||||
modelValue: props.twoFactorEnabled,
|
||||
"onUpdate:modelValue": (value: boolean) => emit('update:twoFactorEnabled', value),
|
||||
onChange: handleToggle2FA
|
||||
})
|
||||
}
|
||||
];
|
||||
// (kept minimal; no dynamic items list needed)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,7 +92,7 @@ const items = [
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
<AppSwitch
|
||||
:model-value="twoFactorEnabled"
|
||||
@update:model-value="emit('update:twoFactorEnabled', $event)"
|
||||
@change="handleToggle2FA"
|
||||
@@ -125,22 +113,18 @@ const items = [
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Change Password"
|
||||
@click="$emit('change-password')"
|
||||
size="small"
|
||||
>
|
||||
<AppButton size="sm" @click="$emit('change-password')">
|
||||
Change Password
|
||||
</Button>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Setup Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="twoFactorDialogVisible"
|
||||
modal
|
||||
header="Enable Two-Factor Authentication"
|
||||
:style="{ width: '26rem' }"
|
||||
<AppDialog
|
||||
:visible="twoFactorDialogVisible"
|
||||
@update:visible="twoFactorDialogVisible = $event"
|
||||
title="Enable Two-Factor Authentication"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
@@ -168,31 +152,30 @@ const items = [
|
||||
<!-- Verification Code Input -->
|
||||
<div class="grid gap-2">
|
||||
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="twoFactorCode"
|
||||
v-model="twoFactorCode"
|
||||
placeholder="Enter 6-digit code"
|
||||
maxlength="6"
|
||||
class="w-full"
|
||||
:maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
label="Cancel"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="twoFactorDialogVisible = false"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Verify & Enable"
|
||||
@click="confirmTwoFactor"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
|
||||
<template #icon>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="confirmTwoFactor">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Verify & Enable
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import InfoIcon from '@/components/icons/InfoIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { ref } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Dialog from 'primevue/dialog';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
|
||||
// VAST Templates
|
||||
interface VastTemplate {
|
||||
@@ -132,10 +137,8 @@ const handleDelete = (template: VastTemplate) => {
|
||||
confirm.require({
|
||||
message: `Are you sure you want to delete "${template.name}"?`,
|
||||
header: 'Delete Template',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
const index = templates.value.findIndex(t => t.id === template.id);
|
||||
if (index !== -1) templates.value.splice(index, 1);
|
||||
@@ -178,19 +181,18 @@ const getAdFormatColor = (format: string) => {
|
||||
Create and manage VAST ad templates for your videos.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
label="Create Template"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
@click="openAddDialog"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton size="sm" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Create Template
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-info-circle text-info text-sm mt-0.5"></i>
|
||||
<InfoIcon class="w-4 h-4 text-info mt-0.5" />
|
||||
<div class="text-xs text-foreground/70">
|
||||
VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.
|
||||
</div>
|
||||
@@ -232,42 +234,37 @@ const getAdFormatColor = (format: string) => {
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2 max-w-[200px]">
|
||||
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
size="small"
|
||||
@click="copyToClipboard(template.vastUrl)"
|
||||
/>
|
||||
<AppButton variant="ghost" size="sm" @click="copyToClipboard(template.vastUrl)">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-center">
|
||||
<ToggleSwitch
|
||||
<AppSwitch
|
||||
:model-value="template.enabled"
|
||||
@update:model-value="handleToggle(template)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="openEditDialog(template)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
size="small"
|
||||
@click="handleDelete(template)"
|
||||
/>
|
||||
<AppButton variant="ghost" size="sm" @click="openEditDialog(template)">
|
||||
<template #icon>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="sm" @click="handleDelete(template)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="templates.length === 0">
|
||||
<td colspan="5" class="px-6 py-12 text-center">
|
||||
<i class="pi pi-play-circle text-3xl text-foreground/30 mb-3 block"></i>
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">No VAST templates yet</p>
|
||||
<p class="text-xs text-foreground/40">Create a template to start monetizing your videos</p>
|
||||
</td>
|
||||
@@ -277,31 +274,28 @@ const getAdFormatColor = (format: string) => {
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showAddDialog"
|
||||
:header="editingTemplate ? 'Edit Template' : 'Create VAST Template'"
|
||||
:modal="true"
|
||||
:closable="true"
|
||||
class="w-full max-w-lg"
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
:title="editingTemplate ? 'Edit Template' : 'Create VAST Template'"
|
||||
maxWidthClass="max-w-lg"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="name" class="text-sm font-medium text-foreground">Template Name</label>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="e.g., Main Pre-roll Ad"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="vastUrl" class="text-sm font-medium text-foreground">VAST Tag URL</label>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="vastUrl"
|
||||
v-model="formData.vastUrl"
|
||||
placeholder="https://ads.example.com/vast/tag.xml"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -317,8 +311,7 @@ const getAdFormatColor = (format: string) => {
|
||||
formData.adFormat === format
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-foreground/60 hover:border-primary/50'
|
||||
]"
|
||||
>
|
||||
]">
|
||||
{{ format }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -326,26 +319,30 @@ const getAdFormatColor = (format: string) => {
|
||||
|
||||
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
|
||||
<label for="duration" class="text-sm font-medium text-foreground">Ad Interval (seconds)</label>
|
||||
<InputNumber
|
||||
<AppInput
|
||||
id="duration"
|
||||
v-model="formData.duration"
|
||||
v-model.number="formData.duration"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
:min="10"
|
||||
:max="600"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancel" text @click="showAddDialog = false" />
|
||||
<Button
|
||||
:label="editingTemplate ? 'Update' : 'Create'"
|
||||
icon="pi pi-check"
|
||||
@click="handleSave"
|
||||
class="press-animated"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</Dialog>
|
||||
{{ editingTemplate ? 'Update' : 'Create' }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
|
||||
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import UploadIcon from '@/components/icons/UploadIcon.vue';
|
||||
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
|
||||
import UploadIcon from '@/components/icons/UploadIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const { data, isPending, isLoading } = useQuery({
|
||||
@@ -26,7 +27,7 @@ const subscribing = ref<string | null>(null);
|
||||
|
||||
// Top-up state
|
||||
const topupDialogVisible = ref(false);
|
||||
const topupAmount = ref<number | null>(null);
|
||||
const topupAmount = ref<number | null>(0);
|
||||
const topupLoading = ref(false);
|
||||
const topupPresets = [10, 20, 50, 100];
|
||||
|
||||
@@ -209,73 +210,13 @@ const selectPreset = (amount: number) => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Top Up"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
@click="openTopupDialog"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton size="sm" @click="openTopupDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Top Up
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Current Plan -->
|
||||
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<CreditCardIcon class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ currentPlan?.name || 'Standard Plan' }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
${{ currentPlan?.price || 0 }}/month
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
|
||||
</div>
|
||||
|
||||
<!-- Storage Usage -->
|
||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<ActivityIcon class="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Storage</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
||||
<div
|
||||
class="bg-primary h-full rounded-full transition-all duration-300"
|
||||
:style="{ width: `${storagePercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uploads Usage -->
|
||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
||||
<UploadIcon class="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Monthly Uploads</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
{{ uploadsUsed }} of {{ uploadsLimit }} uploads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
||||
<div
|
||||
class="bg-info h-full rounded-full transition-all duration-300"
|
||||
:style="{ width: `${uploadsPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Plans -->
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
@@ -345,6 +286,47 @@ const selectPreset = (amount: number) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Storage Usage -->
|
||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<ActivityIcon class="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Storage</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
||||
<div
|
||||
class="bg-primary h-full rounded-full transition-all duration-300"
|
||||
:style="{ width: `${storagePercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uploads Usage -->
|
||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
||||
<UploadIcon class="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Monthly Uploads</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
{{ uploadsUsed }} of {{ uploadsLimit }} uploads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
||||
<div
|
||||
class="bg-info h-full rounded-full transition-all duration-300"
|
||||
:style="{ width: `${uploadsPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment History -->
|
||||
<div class="px-6 py-4">
|
||||
@@ -415,11 +397,11 @@ const selectPreset = (amount: number) => {
|
||||
</div>
|
||||
|
||||
<!-- Top-up Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="topupDialogVisible"
|
||||
modal
|
||||
header="Top Up Wallet"
|
||||
:style="{ width: '28rem' }"
|
||||
<AppDialog
|
||||
:visible="topupDialogVisible"
|
||||
@update:visible="topupDialogVisible = $event"
|
||||
title="Top Up Wallet"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
@@ -448,11 +430,11 @@ const selectPreset = (amount: number) => {
|
||||
<label class="text-sm font-medium text-foreground">Custom Amount</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold text-foreground">$</span>
|
||||
<InputText
|
||||
<AppInput
|
||||
v-model.number="topupAmount"
|
||||
type="number"
|
||||
placeholder="Enter amount"
|
||||
class="flex-1"
|
||||
inputClass="flex-1"
|
||||
min="1"
|
||||
step="1"
|
||||
/>
|
||||
@@ -465,22 +447,28 @@ const selectPreset = (amount: number) => {
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Cancel"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="topupDialogVisible = false"
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="topupLoading"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Proceed to Payment"
|
||||
@click="handleTopup(topupAmount || 0)"
|
||||
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
|
||||
@click="topupDialogVisible = false"
|
||||
>
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="topupLoading"
|
||||
class="press-animated"
|
||||
/>
|
||||
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
|
||||
@click="handleTopup(topupAmount || 0)"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</Dialog>
|
||||
Proceed to Payment
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import Button from 'primevue/button';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
||||
import InfoIcon from '@/components/icons/InfoIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
confirm.require({
|
||||
message: 'Are you sure you want to delete your account? This action cannot be undone.',
|
||||
header: 'Delete Account',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
@@ -30,10 +31,8 @@ const handleClearData = () => {
|
||||
confirm.require({
|
||||
message: 'Are you sure you want to clear all your data? This action cannot be undone.',
|
||||
header: 'Clear All Data',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Clear',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
@@ -71,14 +70,12 @@ const handleClearData = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Delete Account"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
size="small"
|
||||
@click="handleDeleteAccount"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton variant="danger" size="sm" @click="handleDeleteAccount">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Delete Account
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Clear All Data -->
|
||||
@@ -98,22 +95,19 @@ const handleClearData = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Clear Data"
|
||||
icon="pi pi-eraser"
|
||||
severity="danger"
|
||||
size="small"
|
||||
outlined
|
||||
@click="handleClearData"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton variant="danger" size="sm" @click="handleClearData">
|
||||
<template #icon>
|
||||
<SlidersIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Clear Data
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Banner -->
|
||||
<div class="mx-6 mt-4 border border-warning/30 bg-warning/5 rounded-md p-4">
|
||||
<div class="mx-6 my-4 border border-warning/30 bg-warning/5 rounded-md p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-exclamation-triangle text-warning text-sm mt-0.5"></i>
|
||||
<InfoIcon class="w-4 h-4 text-warning mt-0.5" />
|
||||
<div class="text-xs text-foreground/70">
|
||||
<p class="font-medium text-foreground mb-1">Warning</p>
|
||||
<p>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import InfoIcon from '@/components/icons/InfoIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
|
||||
// Domain whitelist for iframe embedding
|
||||
const domains = ref([
|
||||
@@ -42,9 +47,10 @@ const handleAddDomain = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainName = newDomain.value.trim().toLowerCase();
|
||||
domains.value.push({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: newDomain.value.trim().toLowerCase(),
|
||||
name: domainName,
|
||||
addedAt: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
@@ -53,7 +59,7 @@ const handleAddDomain = () => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Domain Added',
|
||||
detail: `${newDomain.value} has been added to your whitelist.`,
|
||||
detail: `${domainName} has been added to your whitelist.`,
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
@@ -62,10 +68,8 @@ const handleRemoveDomain = (domain: typeof domains.value[0]) => {
|
||||
confirm.require({
|
||||
message: `Are you sure you want to remove ${domain.name} from your whitelist? Embedded iframes from this domain will no longer work.`,
|
||||
header: 'Remove Domain',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Remove',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
const index = domains.value.findIndex(d => d.id === domain.id);
|
||||
if (index !== -1) {
|
||||
@@ -106,19 +110,18 @@ const copyIframeCode = () => {
|
||||
Add domains to your whitelist to allow embedding content via iframe.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
label="Add Domain"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
@click="showAddDialog = true"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton size="sm" @click="showAddDialog = true">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Add Domain
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-info-circle text-info text-sm mt-0.5"></i>
|
||||
<InfoIcon class="w-4 h-4 text-info mt-0.5" />
|
||||
<div class="text-xs text-foreground/70">
|
||||
Only domains in your whitelist can embed your content using iframe.
|
||||
</div>
|
||||
@@ -143,24 +146,22 @@ const copyIframeCode = () => {
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-globe text-foreground/40 text-sm"></i>
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
size="small"
|
||||
@click="handleRemoveDomain(domain)"
|
||||
/>
|
||||
<AppButton variant="ghost" size="sm" @click="handleRemoveDomain(domain)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="domains.length === 0">
|
||||
<td colspan="3" class="px-6 py-12 text-center">
|
||||
<i class="pi pi-globe text-3xl text-foreground/30 mb-3 block"></i>
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">No domains in whitelist</p>
|
||||
<p class="text-xs text-foreground/40">Add a domain to allow iframe embedding</p>
|
||||
</td>
|
||||
@@ -173,13 +174,12 @@ const copyIframeCode = () => {
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-medium text-foreground">Embed Code</h4>
|
||||
<Button
|
||||
label="Copy Code"
|
||||
icon="pi pi-copy"
|
||||
size="small"
|
||||
text
|
||||
@click="copyIframeCode"
|
||||
/>
|
||||
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Copy Code
|
||||
</AppButton>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/60 mb-2">
|
||||
Use this iframe code to embed content on your whitelisted domains.
|
||||
@@ -188,29 +188,27 @@ const copyIframeCode = () => {
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showAddDialog"
|
||||
header="Add Domain to Whitelist"
|
||||
:modal="true"
|
||||
:closable="true"
|
||||
class="w-full max-w-md"
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
title="Add Domain to Whitelist"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="domain" class="text-sm font-medium text-foreground">Domain Name</label>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="domain"
|
||||
v-model="newDomain"
|
||||
placeholder="example.com"
|
||||
class="w-full"
|
||||
@keyup.enter="handleAddDomain"
|
||||
@enter="handleAddDomain"
|
||||
/>
|
||||
<p class="text-xs text-foreground/50">Enter domain without www or https:// (e.g., example.com)</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-warning/5 border border-warning/20 rounded-md p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="pi pi-exclamation-triangle text-warning text-sm mt-0.5"></i>
|
||||
<AlertTriangleIcon class="w-4 h-4 text-warning mt-0.5" />
|
||||
<div class="text-xs text-foreground/70">
|
||||
<p class="font-medium text-foreground mb-1">Important</p>
|
||||
<p>Only add domains that you own and control.</p>
|
||||
@@ -220,18 +218,16 @@ const copyIframeCode = () => {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Cancel"
|
||||
text
|
||||
@click="showAddDialog = false"
|
||||
/>
|
||||
<Button
|
||||
label="Add Domain"
|
||||
icon="pi pi-check"
|
||||
@click="handleAddDomain"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="handleAddDomain">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</Dialog>
|
||||
Add Domain
|
||||
</AppButton>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import MailIcon from '@/components/icons/MailIcon.vue';
|
||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||
import SendIcon from '@/components/icons/SendIcon.vue';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||
|
||||
const toast = useToast();
|
||||
const toast = useAppToast();
|
||||
|
||||
const notificationSettings = ref({
|
||||
email: true,
|
||||
@@ -88,14 +89,16 @@ const handleSave = async () => {
|
||||
Choose how you want to receive notifications and updates.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
label="Save Changes"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
class="press-animated"
|
||||
/>
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Save Changes
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -116,7 +119,7 @@ const handleSave = async () => {
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="notificationSettings[type.key]" />
|
||||
<AppSwitch v-model="notificationSettings[type.key]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import PlayIcon from '@/components/icons/PlayIcon.vue';
|
||||
import RepeatIcon from '@/components/icons/RepeatIcon.vue';
|
||||
import VolumeOffIcon from '@/components/icons/VolumeOffIcon.vue';
|
||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
import ImageIcon from '@/components/icons/ImageIcon.vue';
|
||||
|
||||
const toast = useToast();
|
||||
const toast = useAppToast();
|
||||
|
||||
const playerSettings = ref({
|
||||
autoplay: true,
|
||||
@@ -102,14 +103,16 @@ const settingsItems = [
|
||||
Configure default video player behavior and features.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
label="Save Changes"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
class="press-animated"
|
||||
/>
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Save Changes
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -130,7 +133,7 @@ const settingsItems = [
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="playerSettings[item.key]" />
|
||||
<AppSwitch v-model="playerSettings[item.key]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { ref } from 'vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LockIcon from '@/components/icons/LockIcon.vue';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
const toast = useAppToast();
|
||||
|
||||
// 2FA state
|
||||
const twoFactorEnabled = ref(false);
|
||||
@@ -219,7 +218,7 @@ const disconnectTelegram = async () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
|
||||
<AppSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
@@ -235,12 +234,9 @@ const disconnectTelegram = async () => {
|
||||
<p class="text-xs text-foreground/60 mt-0.5">Update your account password</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Change Password"
|
||||
@click="openChangePassword"
|
||||
size="small"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton size="sm" @click="openChangePassword">
|
||||
Change Password
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Email Connection -->
|
||||
@@ -277,31 +273,30 @@ const disconnectTelegram = async () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
<AppButton
|
||||
v-if="telegramConnected"
|
||||
label="Disconnect"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="disconnectTelegram"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
>
|
||||
Disconnect
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-else
|
||||
label="Connect"
|
||||
size="small"
|
||||
size="sm"
|
||||
@click="connectTelegram"
|
||||
class="press-animated"
|
||||
/>
|
||||
>
|
||||
Connect
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Setup Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="twoFactorDialogVisible"
|
||||
modal
|
||||
header="Enable Two-Factor Authentication"
|
||||
:style="{ width: '26rem' }"
|
||||
<AppDialog
|
||||
:visible="twoFactorDialogVisible"
|
||||
@update:visible="twoFactorDialogVisible = $event"
|
||||
title="Enable Two-Factor Authentication"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
@@ -329,40 +324,35 @@ const disconnectTelegram = async () => {
|
||||
<!-- Verification Code Input -->
|
||||
<div class="grid gap-2">
|
||||
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="twoFactorCode"
|
||||
v-model="twoFactorCode"
|
||||
placeholder="Enter 6-digit code"
|
||||
maxlength="6"
|
||||
class="w-full"
|
||||
:maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
label="Cancel"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="twoFactorDialogVisible = false"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Verify & Enable"
|
||||
@click="confirmTwoFactor"
|
||||
class="press-animated"
|
||||
/>
|
||||
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="confirmTwoFactor">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Verify & Enable
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<Dialog
|
||||
<AppDialog
|
||||
:visible="changePasswordDialogVisible"
|
||||
@update:visible="changePasswordDialogVisible = $event"
|
||||
modal
|
||||
header="Change Password"
|
||||
:style="{ width: '26rem' }"
|
||||
title="Change Password"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
@@ -377,72 +367,70 @@ const disconnectTelegram = async () => {
|
||||
<!-- Current Password -->
|
||||
<div class="grid gap-2">
|
||||
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="currentPassword"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
class="w-full"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div class="grid gap-2">
|
||||
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="newPassword"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
class="w-full"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="grid gap-2">
|
||||
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</InputIcon>
|
||||
<InputText
|
||||
<AppInput
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
class="w-full"
|
||||
/>
|
||||
</IconField>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockIcon class="w-5 h-5" />
|
||||
</template>
|
||||
</AppInput>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
label="Cancel"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="changePasswordDialogVisible = false"
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="changePasswordLoading"
|
||||
class="press-animated"
|
||||
/>
|
||||
<Button
|
||||
label="Change Password"
|
||||
@click="changePassword"
|
||||
@click="changePasswordDialogVisible = false"
|
||||
>
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="changePasswordLoading"
|
||||
class="press-animated"
|
||||
/>
|
||||
@click="changePassword"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
Change Password
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user