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:
2026-03-04 18:32:17 +07:00
parent 16caa9281b
commit 77ece5224d
21 changed files with 1137 additions and 576 deletions

28
components.d.ts vendored
View File

@@ -17,6 +17,13 @@ declare module 'vue' {
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default'] AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default'] AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.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'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
Bell: typeof import('./src/components/icons/Bell.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'] HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default'] HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
Home: typeof import('./src/components/icons/Home.vue')['default'] Home: typeof import('./src/components/icons/Home.vue')['default']
IconField: typeof import('primevue/iconfield')['default']
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default'] ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default'] InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputNumber: typeof import('primevue/inputnumber')['default']
InputText: typeof import('primevue/inputtext')['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'] Layout: typeof import('./src/components/icons/Layout.vue')['default']
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default'] LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default'] LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
LockIcon: typeof import('./src/components/icons/LockIcon.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'] MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
Message: typeof import('primevue/message')['default'] Message: typeof import('primevue/message')['default']
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default'] MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
@@ -74,14 +76,12 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default'] SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.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'] Skeleton: typeof import('primevue/skeleton')['default']
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default'] SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default'] StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
Tag: typeof import('primevue/tag')['default'] Tag: typeof import('primevue/tag')['default']
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default'] TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
TestIcon: typeof import('./src/components/icons/TestIcon.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'] TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadIcon: typeof import('./src/components/icons/UploadIcon.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 AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default'] const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.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 ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.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 HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default'] const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
const Home: typeof import('./src/components/icons/Home.vue')['default'] const Home: typeof import('./src/components/icons/Home.vue')['default']
const IconField: typeof import('primevue/iconfield')['default']
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default'] const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default'] const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
const InputIcon: typeof import('primevue/inputicon')['default']
const InputNumber: typeof import('primevue/inputnumber')['default']
const InputText: typeof import('primevue/inputtext')['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 Layout: typeof import('./src/components/icons/Layout.vue')['default']
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default'] const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default'] const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
const LockIcon: typeof import('./src/components/icons/LockIcon.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 MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
const Message: typeof import('primevue/message')['default'] const Message: typeof import('primevue/message')['default']
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['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 RouterView: typeof import('vue-router')['RouterView']
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default'] const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.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 Skeleton: typeof import('primevue/skeleton')['default']
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default'] const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default'] const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
const Tag: typeof import('primevue/tag')['default'] const Tag: typeof import('primevue/tag')['default']
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default'] const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
const TestIcon: typeof import('./src/components/icons/TestIcon.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 TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default'] const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']

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

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

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

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

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

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

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

View 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),
};
};

View 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),
};
};

View File

@@ -49,6 +49,12 @@
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="flex-1 min-w-0"> <main class="flex-1 min-w-0">
<router-view /> <router-view />
<!-- Settings-only toast/confirm hosts (no PrimeVue dependency) -->
<ClientOnly>
<AppToastHost />
<AppConfirmHost />
</ClientOnly>
</main> </main>
</div> </div>
</div> </div>
@@ -59,6 +65,9 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue'; 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 UserIcon from '@/components/icons/UserIcon.vue';
import GlobeIcon from '@/components/icons/Globe.vue'; import GlobeIcon from '@/components/icons/Globe.vue';
import AlertTriangle from '@/components/icons/AlertTriangle.vue'; import AlertTriangle from '@/components/icons/AlertTriangle.vue';

View File

@@ -1,15 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import AppButton from '@/components/app/AppButton.vue';
import { useToast } from 'primevue/usetoast'; import AppDialog from '@/components/app/AppDialog.vue';
import InputText from 'primevue/inputtext'; import AppInput from '@/components/app/AppInput.vue';
import IconField from 'primevue/iconfield'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import InputIcon from 'primevue/inputicon';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import LockIcon from '@/components/icons/LockIcon.vue'; import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue'; import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const toast = useToast();
const props = defineProps<{ const props = defineProps<{
dialogVisible: boolean; dialogVisible: boolean;
@@ -82,32 +78,30 @@ const handleChangePassword = () => {
</p> </p>
</div> </div>
</div> </div>
<Button <AppButton
v-if="telegramConnected" v-if="telegramConnected"
label="Disconnect" variant="danger"
size="small" size="sm"
text
severity="danger"
@click="$emit('disconnect-telegram')" @click="$emit('disconnect-telegram')"
class="press-animated" >
/> Disconnect
<Button </AppButton>
<AppButton
v-else v-else
label="Connect" size="sm"
size="small"
@click="$emit('connect-telegram')" @click="$emit('connect-telegram')"
class="press-animated" >
/> Connect
</AppButton>
</div> </div>
</div> </div>
<!-- Change Password Dialog --> <!-- Change Password Dialog -->
<Dialog <AppDialog
:visible="dialogVisible" :visible="dialogVisible"
@update:visible="$emit('update:dialogVisible', $event)" @update:visible="$emit('update:dialogVisible', $event)"
modal title="Change Password"
header="Change Password" maxWidthClass="max-w-md"
:style="{ width: '26rem' }"
> >
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-foreground/70"> <p class="text-sm text-foreground/70">
@@ -122,75 +116,76 @@ const handleChangePassword = () => {
<!-- Current Password --> <!-- Current Password -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label> <label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<IconField> <AppInput
<InputIcon> id="currentPassword"
:model-value="currentPassword"
type="password"
placeholder="Enter current password"
@update:model-value="$emit('update:currentPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" /> <LockIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="currentPassword"
:model-value="currentPassword"
type="password"
placeholder="Enter current password"
class="w-full"
@update:model-value="$emit('update:currentPassword', $event)"
/>
</IconField>
</div> </div>
<!-- New Password --> <!-- New Password -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label> <label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<IconField> <AppInput
<InputIcon> id="newPassword"
:model-value="newPassword"
type="password"
placeholder="Enter new password"
@update:model-value="$emit('update:newPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" /> <LockIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="newPassword"
:model-value="newPassword"
type="password"
placeholder="Enter new password"
class="w-full"
@update:model-value="$emit('update:newPassword', $event)"
/>
</IconField>
</div> </div>
<!-- Confirm Password --> <!-- Confirm Password -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label> <label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<IconField> <AppInput
<InputIcon> id="confirmPassword"
:model-value="confirmPassword"
type="password"
placeholder="Confirm new password"
@update:model-value="$emit('update:confirmPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" /> <LockIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="confirmPassword"
:model-value="confirmPassword"
type="password"
placeholder="Confirm new password"
class="w-full"
@update:model-value="$emit('update:confirmPassword', $event)"
/>
</IconField>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<Button <AppButton
label="Cancel" variant="secondary"
text size="sm"
severity="secondary"
@click="$emit('close')"
:disabled="loading" :disabled="loading"
class="press-animated" @click="$emit('close')"
/> >
<Button <template #icon>
label="Change Password" <XIcon class="w-4 h-4" />
@click="handleChangePassword" </template>
Cancel
</AppButton>
<AppButton
size="sm"
:loading="loading" :loading="loading"
class="press-animated" @click="handleChangePassword"
/> >
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Change Password
</AppButton>
</div> </div>
</template> </template>
</Dialog> </AppDialog>
</div> </div>
</template> </template>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useToast } from 'primevue/usetoast'; import AppButton from '@/components/app/AppButton.vue';
import InputText from 'primevue/inputtext'; import AppInput from '@/components/app/AppInput.vue';
import IconField from 'primevue/iconfield'; import AppProgressBar from '@/components/app/AppProgressBar.vue';
import InputIcon from 'primevue/inputicon'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import ProgressBar from 'primevue/progressbar'; import MailIcon from '@/components/icons/MailIcon.vue';
import Button from 'primevue/button'; import PencilIcon from '@/components/icons/PencilIcon.vue';
import UserIcon from '@/components/icons/UserIcon.vue'; import UserIcon from '@/components/icons/UserIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const toast = useToast();
const props = defineProps<{ const props = defineProps<{
editing: boolean; editing: boolean;
@@ -71,36 +71,31 @@ const formatBytes = (bytes: number) => {
<div class="grid gap-6 max-w-2xl"> <div class="grid gap-6 max-w-2xl">
<div class="grid gap-2"> <div class="grid gap-2">
<label for="username" class="text-sm font-medium text-foreground">Username</label> <label for="username" class="text-sm font-medium text-foreground">Username</label>
<IconField> <AppInput
<InputIcon> id="username"
:model-value="username"
:readonly="!editing"
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
@update:model-value="emit('update:username', String($event))"
>
<template #prefix>
<UserIcon class="w-5 h-5" /> <UserIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="username"
:model-value="username"
:readonly="!editing"
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
@update:model-value="emit('update:username', String($event))"
/>
</IconField>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<label for="email" class="text-sm font-medium text-foreground">Email Address</label> <label for="email" class="text-sm font-medium text-foreground">Email Address</label>
<IconField> <AppInput
<InputIcon> id="email"
<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"> :model-value="email"
<rect width="20" height="16" x="2" y="4" rx="2"/> :readonly="!editing"
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/> :inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
</svg> @update:model-value="emit('update:email', $event || '')"
</InputIcon> >
<InputText <template #prefix>
id="email" <MailIcon class="w-5 h-5" />
:model-value="email" </template>
:readonly="!editing" </AppInput>
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
@update:model-value="emit('update:email', $event|| '')"
/>
</IconField>
</div> </div>
</div> </div>
@@ -120,45 +115,36 @@ const formatBytes = (bytes: number) => {
</div> </div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span> <span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div> </div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px" /> <AppProgressBar :value="storagePercentage" />
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3"> <div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3">
<template v-if="editing"> <template v-if="editing">
<Button <AppButton size="sm" :loading="saving" @click="emit('save')">
label="Save Changes" <template #icon>
size="small" <CheckIcon class="w-4 h-4" />
:loading="saving" </template>
@click="emit('save')" Save Changes
class="press-animated" </AppButton>
/> <AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('cancel-edit')">
<Button <template #icon>
label="Cancel" <XIcon class="w-4 h-4" />
size="small" </template>
text Cancel
severity="secondary" </AppButton>
@click="emit('cancel-edit')"
:disabled="saving"
class="press-animated"
/>
</template> </template>
<template v-else> <template v-else>
<Button <AppButton size="sm" @click="emit('start-edit')">
label="Edit Profile" <template #icon>
size="small" <PencilIcon class="w-4 h-4" />
@click="emit('start-edit')" </template>
class="press-animated" Edit Profile
/> </AppButton>
<Button <AppButton variant="secondary" size="sm" @click="emit('change-password')">
label="Change Password" Change Password
size="small" </AppButton>
text
severity="secondary"
@click="emit('change-password')"
class="press-animated"
/>
</template> </template>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, h } from 'vue'; import { ref } from 'vue';
import { useToast } from 'primevue/usetoast'; import AppButton from '@/components/app/AppButton.vue';
import InputText from 'primevue/inputtext'; import AppDialog from '@/components/app/AppDialog.vue';
import IconField from 'primevue/iconfield'; import AppInput from '@/components/app/AppInput.vue';
import InputIcon from 'primevue/inputicon'; import AppSwitch from '@/components/app/AppSwitch.vue';
import ToggleSwitch from 'primevue/toggleswitch'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import LockIcon from '@/components/icons/LockIcon.vue'; import LockIcon from '@/components/icons/LockIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const toast = useToast();
const props = defineProps<{ const props = defineProps<{
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
@@ -49,17 +46,8 @@ const confirmTwoFactor = async () => {
twoFactorDialogVisible.value = false; twoFactorDialogVisible.value = false;
twoFactorCode.value = ''; twoFactorCode.value = '';
}; };
const items = [ // (kept minimal; no dynamic items list needed)
{
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
})
}
];
</script> </script>
<template> <template>
@@ -104,7 +92,7 @@ const items = [
</p> </p>
</div> </div>
</div> </div>
<ToggleSwitch <AppSwitch
:model-value="twoFactorEnabled" :model-value="twoFactorEnabled"
@update:model-value="emit('update:twoFactorEnabled', $event)" @update:model-value="emit('update:twoFactorEnabled', $event)"
@change="handleToggle2FA" @change="handleToggle2FA"
@@ -125,22 +113,18 @@ const items = [
</p> </p>
</div> </div>
</div> </div>
<Button <AppButton size="sm" @click="$emit('change-password')">
label="Change Password" Change Password
@click="$emit('change-password')" </AppButton>
size="small"
>
Change Password
</Button>
</div> </div>
</div> </div>
<!-- 2FA Setup Dialog --> <!-- 2FA Setup Dialog -->
<Dialog <AppDialog
v-model:visible="twoFactorDialogVisible" :visible="twoFactorDialogVisible"
modal @update:visible="twoFactorDialogVisible = $event"
header="Enable Two-Factor Authentication" title="Enable Two-Factor Authentication"
:style="{ width: '26rem' }" maxWidthClass="max-w-md"
> >
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-foreground/70"> <p class="text-sm text-foreground/70">
@@ -168,31 +152,30 @@ const items = [
<!-- Verification Code Input --> <!-- Verification Code Input -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label> <label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<InputText <AppInput
id="twoFactorCode" id="twoFactorCode"
v-model="twoFactorCode" v-model="twoFactorCode"
placeholder="Enter 6-digit code" placeholder="Enter 6-digit code"
maxlength="6" :maxlength="6"
class="w-full"
/> />
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<Button <AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
label="Cancel" <template #icon>
text <XIcon class="w-4 h-4" />
severity="secondary" </template>
@click="twoFactorDialogVisible = false" Cancel
class="press-animated" </AppButton>
/> <AppButton size="sm" @click="confirmTwoFactor">
<Button <template #icon>
label="Verify & Enable" <CheckIcon class="w-4 h-4" />
@click="confirmTwoFactor" </template>
class="press-animated" Verify & Enable
/> </AppButton>
</div> </div>
</template> </template>
</Dialog> </AppDialog>
</div> </div>
</template> </template>

View File

@@ -1,15 +1,20 @@
<script setup lang="ts"> <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 { 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 toast = useAppToast();
const confirm = useConfirm(); const confirm = useAppConfirm();
// VAST Templates // VAST Templates
interface VastTemplate { interface VastTemplate {
@@ -132,10 +137,8 @@ const handleDelete = (template: VastTemplate) => {
confirm.require({ confirm.require({
message: `Are you sure you want to delete "${template.name}"?`, message: `Are you sure you want to delete "${template.name}"?`,
header: 'Delete Template', header: 'Delete Template',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete', acceptLabel: 'Delete',
rejectLabel: 'Cancel', rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => { accept: () => {
const index = templates.value.findIndex(t => t.id === template.id); const index = templates.value.findIndex(t => t.id === template.id);
if (index !== -1) templates.value.splice(index, 1); 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. Create and manage VAST ad templates for your videos.
</p> </p>
</div> </div>
<Button <AppButton size="sm" @click="openAddDialog">
label="Create Template" <template #icon>
icon="pi pi-plus" <PlusIcon class="w-4 h-4" />
size="small" </template>
@click="openAddDialog" Create Template
class="press-animated" </AppButton>
/>
</div> </div>
<!-- Info Banner --> <!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20"> <div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2"> <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"> <div class="text-xs text-foreground/70">
VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players. VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.
</div> </div>
@@ -232,42 +234,37 @@ const getAdFormatColor = (format: string) => {
<td class="px-6 py-3"> <td class="px-6 py-3">
<div class="flex items-center gap-2 max-w-[200px]"> <div class="flex items-center gap-2 max-w-[200px]">
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code> <code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
<Button <AppButton variant="ghost" size="sm" @click="copyToClipboard(template.vastUrl)">
icon="pi pi-copy" <template #icon>
text <CheckIcon class="w-4 h-4" />
size="small" </template>
@click="copyToClipboard(template.vastUrl)" </AppButton>
/>
</div> </div>
</td> </td>
<td class="px-6 py-3 text-center"> <td class="px-6 py-3 text-center">
<ToggleSwitch <AppSwitch
:model-value="template.enabled" :model-value="template.enabled"
@update:model-value="handleToggle(template)" @update:model-value="handleToggle(template)"
/> />
</td> </td>
<td class="px-6 py-3 text-right"> <td class="px-6 py-3 text-right">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<Button <AppButton variant="ghost" size="sm" @click="openEditDialog(template)">
icon="pi pi-pencil" <template #icon>
text <PencilIcon class="w-4 h-4" />
severity="secondary" </template>
size="small" </AppButton>
@click="openEditDialog(template)" <AppButton variant="ghost" size="sm" @click="handleDelete(template)">
/> <template #icon>
<Button <TrashIcon class="w-4 h-4 text-danger" />
icon="pi pi-trash" </template>
text </AppButton>
severity="danger"
size="small"
@click="handleDelete(template)"
/>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="templates.length === 0"> <tr v-if="templates.length === 0">
<td colspan="5" class="px-6 py-12 text-center"> <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-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> <p class="text-xs text-foreground/40">Create a template to start monetizing your videos</p>
</td> </td>
@@ -277,31 +274,28 @@ const getAdFormatColor = (format: string) => {
</div> </div>
<!-- Add/Edit Dialog --> <!-- Add/Edit Dialog -->
<Dialog <AppDialog
v-model:visible="showAddDialog" :visible="showAddDialog"
:header="editingTemplate ? 'Edit Template' : 'Create VAST Template'" @update:visible="showAddDialog = $event"
:modal="true" :title="editingTemplate ? 'Edit Template' : 'Create VAST Template'"
:closable="true" maxWidthClass="max-w-lg"
class="w-full max-w-lg"
> >
<div class="space-y-4"> <div class="space-y-4">
<div class="grid gap-2"> <div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">Template Name</label> <label for="name" class="text-sm font-medium text-foreground">Template Name</label>
<InputText <AppInput
id="name" id="name"
v-model="formData.name" v-model="formData.name"
placeholder="e.g., Main Pre-roll Ad" placeholder="e.g., Main Pre-roll Ad"
class="w-full"
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<label for="vastUrl" class="text-sm font-medium text-foreground">VAST Tag URL</label> <label for="vastUrl" class="text-sm font-medium text-foreground">VAST Tag URL</label>
<InputText <AppInput
id="vastUrl" id="vastUrl"
v-model="formData.vastUrl" v-model="formData.vastUrl"
placeholder="https://ads.example.com/vast/tag.xml" placeholder="https://ads.example.com/vast/tag.xml"
class="w-full"
/> />
</div> </div>
@@ -317,8 +311,7 @@ const getAdFormatColor = (format: string) => {
formData.adFormat === format formData.adFormat === format
? 'border-primary bg-primary/5 text-primary' ? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50' : 'border-border text-foreground/60 hover:border-primary/50'
]" ]">
>
{{ format }} {{ format }}
</button> </button>
</div> </div>
@@ -326,26 +319,30 @@ const getAdFormatColor = (format: string) => {
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2"> <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> <label for="duration" class="text-sm font-medium text-foreground">Ad Interval (seconds)</label>
<InputNumber <AppInput
id="duration" id="duration"
v-model="formData.duration" v-model.number="formData.duration"
type="number"
placeholder="30" placeholder="30"
:min="10" :min="10"
:max="600" :max="600"
class="w-full"
/> />
</div> </div>
</div> </div>
<template #footer> <template #footer>
<Button label="Cancel" text @click="showAddDialog = false" /> <div class="flex justify-end gap-2">
<Button <AppButton variant="secondary" size="sm" @click="showAddDialog = false">
:label="editingTemplate ? 'Update' : 'Create'" Cancel
icon="pi pi-check" </AppButton>
@click="handleSave" <AppButton size="sm" @click="handleSave">
class="press-animated" <template #icon>
/> <CheckIcon class="w-4 h-4" />
</template>
{{ editingTemplate ? 'Update' : 'Create' }}
</AppButton>
</div>
</template> </template>
</Dialog> </AppDialog>
</div> </div>
</template> </template>

View File

@@ -1,20 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { client, type ModelPlan } from '@/api/client'; import { client, type ModelPlan } from '@/api/client';
import { useAuthStore } from '@/stores/auth'; import ActivityIcon from '@/components/icons/ActivityIcon.vue';
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 CoinsIcon from '@/components/icons/CoinsIcon.vue'; import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.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 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 auth = useAuthStore();
const { data, isPending, isLoading } = useQuery({ const { data, isPending, isLoading } = useQuery({
@@ -26,7 +27,7 @@ const subscribing = ref<string | null>(null);
// Top-up state // Top-up state
const topupDialogVisible = ref(false); const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null); const topupAmount = ref<number | null>(0);
const topupLoading = ref(false); const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100]; const topupPresets = [10, 20, 50, 100];
@@ -209,74 +210,14 @@ const selectPreset = (amount: number) => {
</p> </p>
</div> </div>
</div> </div>
<Button <AppButton size="sm" @click="openTopupDialog">
label="Top Up" <template #icon>
icon="pi pi-plus" <PlusIcon class="w-4 h-4" />
size="small" </template>
@click="openTopupDialog" Top Up
class="press-animated" </AppButton>
/>
</div> </div>
<!-- Available Plans -->
<!-- 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="px-6 py-4">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0"> <div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
@@ -345,6 +286,47 @@ const selectPreset = (amount: number) => {
</div> </div>
</div> </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 --> <!-- Payment History -->
<div class="px-6 py-4"> <div class="px-6 py-4">
@@ -415,11 +397,11 @@ const selectPreset = (amount: number) => {
</div> </div>
<!-- Top-up Dialog --> <!-- Top-up Dialog -->
<Dialog <AppDialog
v-model:visible="topupDialogVisible" :visible="topupDialogVisible"
modal @update:visible="topupDialogVisible = $event"
header="Top Up Wallet" title="Top Up Wallet"
:style="{ width: '28rem' }" maxWidthClass="max-w-md"
> >
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-foreground/70"> <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> <label class="text-sm font-medium text-foreground">Custom Amount</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span> <span class="text-lg font-semibold text-foreground">$</span>
<InputText <AppInput
v-model.number="topupAmount" v-model.number="topupAmount"
type="number" type="number"
placeholder="Enter amount" placeholder="Enter amount"
class="flex-1" inputClass="flex-1"
min="1" min="1"
step="1" step="1"
/> />
@@ -465,22 +447,28 @@ const selectPreset = (amount: number) => {
</div> </div>
</div> </div>
<template #footer> <template #footer>
<Button <div class="flex justify-end gap-2">
label="Cancel" <AppButton
text variant="secondary"
severity="secondary" size="sm"
@click="topupDialogVisible = false" :disabled="topupLoading"
:disabled="topupLoading" @click="topupDialogVisible = false"
class="press-animated" >
/> Cancel
<Button </AppButton>
label="Proceed to Payment" <AppButton
@click="handleTopup(topupAmount || 0)" size="sm"
:disabled="!topupAmount || topupAmount < 1 || topupLoading" :loading="topupLoading"
:loading="topupLoading" :disabled="!topupAmount || topupAmount < 1 || topupLoading"
class="press-animated" @click="handleTopup(topupAmount || 0)"
/> >
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Proceed to Payment
</AppButton>
</div>
</template> </template>
</Dialog> </AppDialog>
</div> </div>
</template> </template>

View File

@@ -1,20 +1,21 @@
<script setup lang="ts"> <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 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 toast = useAppToast();
const confirm = useConfirm(); const confirm = useAppConfirm();
const handleDeleteAccount = () => { const handleDeleteAccount = () => {
confirm.require({ confirm.require({
message: 'Are you sure you want to delete your account? This action cannot be undone.', message: 'Are you sure you want to delete your account? This action cannot be undone.',
header: 'Delete Account', header: 'Delete Account',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete', acceptLabel: 'Delete',
rejectLabel: 'Cancel', rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => { accept: () => {
toast.add({ toast.add({
severity: 'info', severity: 'info',
@@ -30,10 +31,8 @@ const handleClearData = () => {
confirm.require({ confirm.require({
message: 'Are you sure you want to clear all your data? This action cannot be undone.', message: 'Are you sure you want to clear all your data? This action cannot be undone.',
header: 'Clear All Data', header: 'Clear All Data',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Clear', acceptLabel: 'Clear',
rejectLabel: 'Cancel', rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => { accept: () => {
toast.add({ toast.add({
severity: 'info', severity: 'info',
@@ -71,14 +70,12 @@ const handleClearData = () => {
</p> </p>
</div> </div>
</div> </div>
<Button <AppButton variant="danger" size="sm" @click="handleDeleteAccount">
label="Delete Account" <template #icon>
icon="pi pi-trash" <TrashIcon class="w-4 h-4" />
severity="danger" </template>
size="small" Delete Account
@click="handleDeleteAccount" </AppButton>
class="press-animated"
/>
</div> </div>
<!-- Clear All Data --> <!-- Clear All Data -->
@@ -98,22 +95,19 @@ const handleClearData = () => {
</p> </p>
</div> </div>
</div> </div>
<Button <AppButton variant="danger" size="sm" @click="handleClearData">
label="Clear Data" <template #icon>
icon="pi pi-eraser" <SlidersIcon class="w-4 h-4" />
severity="danger" </template>
size="small" Clear Data
outlined </AppButton>
@click="handleClearData"
class="press-animated"
/>
</div> </div>
</div> </div>
<!-- Warning Banner --> <!-- 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"> <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"> <div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Warning</p> <p class="font-medium text-foreground mb-1">Warning</p>
<p> <p>

View File

@@ -1,14 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useToast } from 'primevue/usetoast'; import AppButton from '@/components/app/AppButton.vue';
import { useConfirm } from 'primevue/useconfirm'; import AppDialog from '@/components/app/AppDialog.vue';
import ToggleSwitch from 'primevue/toggleswitch'; import AppInput from '@/components/app/AppInput.vue';
import Button from 'primevue/button'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import InputText from 'primevue/inputtext'; import InfoIcon from '@/components/icons/InfoIcon.vue';
import Dialog from 'primevue/dialog'; 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 toast = useAppToast();
const confirm = useConfirm(); const confirm = useAppConfirm();
// Domain whitelist for iframe embedding // Domain whitelist for iframe embedding
const domains = ref([ const domains = ref([
@@ -42,9 +47,10 @@ const handleAddDomain = () => {
return; return;
} }
const domainName = newDomain.value.trim().toLowerCase();
domains.value.push({ domains.value.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
name: newDomain.value.trim().toLowerCase(), name: domainName,
addedAt: new Date().toISOString().split('T')[0] addedAt: new Date().toISOString().split('T')[0]
}); });
@@ -53,7 +59,7 @@ const handleAddDomain = () => {
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Domain Added', summary: 'Domain Added',
detail: `${newDomain.value} has been added to your whitelist.`, detail: `${domainName} has been added to your whitelist.`,
life: 3000 life: 3000
}); });
}; };
@@ -62,10 +68,8 @@ const handleRemoveDomain = (domain: typeof domains.value[0]) => {
confirm.require({ confirm.require({
message: `Are you sure you want to remove ${domain.name} from your whitelist? Embedded iframes from this domain will no longer work.`, 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', header: 'Remove Domain',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Remove', acceptLabel: 'Remove',
rejectLabel: 'Cancel', rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => { accept: () => {
const index = domains.value.findIndex(d => d.id === domain.id); const index = domains.value.findIndex(d => d.id === domain.id);
if (index !== -1) { if (index !== -1) {
@@ -106,19 +110,18 @@ const copyIframeCode = () => {
Add domains to your whitelist to allow embedding content via iframe. Add domains to your whitelist to allow embedding content via iframe.
</p> </p>
</div> </div>
<Button <AppButton size="sm" @click="showAddDialog = true">
label="Add Domain" <template #icon>
icon="pi pi-plus" <PlusIcon class="w-4 h-4" />
size="small" </template>
@click="showAddDialog = true" Add Domain
class="press-animated" </AppButton>
/>
</div> </div>
<!-- Info Banner --> <!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20"> <div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2"> <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"> <div class="text-xs text-foreground/70">
Only domains in your whitelist can embed your content using iframe. Only domains in your whitelist can embed your content using iframe.
</div> </div>
@@ -143,24 +146,22 @@ const copyIframeCode = () => {
> >
<td class="px-6 py-3"> <td class="px-6 py-3">
<div class="flex items-center gap-2"> <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> <span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
</div> </div>
</td> </td>
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td> <td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
<td class="px-6 py-3 text-right"> <td class="px-6 py-3 text-right">
<Button <AppButton variant="ghost" size="sm" @click="handleRemoveDomain(domain)">
icon="pi pi-trash" <template #icon>
text <TrashIcon class="w-4 h-4 text-danger" />
severity="danger" </template>
size="small" </AppButton>
@click="handleRemoveDomain(domain)"
/>
</td> </td>
</tr> </tr>
<tr v-if="domains.length === 0"> <tr v-if="domains.length === 0">
<td colspan="3" class="px-6 py-12 text-center"> <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-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> <p class="text-xs text-foreground/40">Add a domain to allow iframe embedding</p>
</td> </td>
@@ -173,13 +174,12 @@ const copyIframeCode = () => {
<div class="px-6 py-4 bg-muted/30"> <div class="px-6 py-4 bg-muted/30">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-foreground">Embed Code</h4> <h4 class="text-sm font-medium text-foreground">Embed Code</h4>
<Button <AppButton variant="secondary" size="sm" @click="copyIframeCode">
label="Copy Code" <template #icon>
icon="pi pi-copy" <CheckIcon class="w-4 h-4" />
size="small" </template>
text Copy Code
@click="copyIframeCode" </AppButton>
/>
</div> </div>
<p class="text-xs text-foreground/60 mb-2"> <p class="text-xs text-foreground/60 mb-2">
Use this iframe code to embed content on your whitelisted domains. Use this iframe code to embed content on your whitelisted domains.
@@ -188,29 +188,27 @@ const copyIframeCode = () => {
</div> </div>
<!-- Add Domain Dialog --> <!-- Add Domain Dialog -->
<Dialog <AppDialog
v-model:visible="showAddDialog" :visible="showAddDialog"
header="Add Domain to Whitelist" @update:visible="showAddDialog = $event"
:modal="true" title="Add Domain to Whitelist"
:closable="true" maxWidthClass="max-w-md"
class="w-full max-w-md"
> >
<div class="space-y-4"> <div class="space-y-4">
<div class="grid gap-2"> <div class="grid gap-2">
<label for="domain" class="text-sm font-medium text-foreground">Domain Name</label> <label for="domain" class="text-sm font-medium text-foreground">Domain Name</label>
<InputText <AppInput
id="domain" id="domain"
v-model="newDomain" v-model="newDomain"
placeholder="example.com" placeholder="example.com"
class="w-full" @enter="handleAddDomain"
@keyup.enter="handleAddDomain"
/> />
<p class="text-xs text-foreground/50">Enter domain without www or https:// (e.g., example.com)</p> <p class="text-xs text-foreground/50">Enter domain without www or https:// (e.g., example.com)</p>
</div> </div>
<div class="bg-warning/5 border border-warning/20 rounded-md p-3"> <div class="bg-warning/5 border border-warning/20 rounded-md p-3">
<div class="flex items-start gap-2"> <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"> <div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Important</p> <p class="font-medium text-foreground mb-1">Important</p>
<p>Only add domains that you own and control.</p> <p>Only add domains that you own and control.</p>
@@ -220,18 +218,16 @@ const copyIframeCode = () => {
</div> </div>
<template #footer> <template #footer>
<Button <AppButton variant="secondary" size="sm" @click="showAddDialog = false">
label="Cancel" Cancel
text </AppButton>
@click="showAddDialog = false" <AppButton size="sm" @click="handleAddDomain">
/> <template #icon>
<Button <CheckIcon class="w-4 h-4" />
label="Add Domain" </template>
icon="pi pi-check" Add Domain
@click="handleAddDomain" </AppButton>
class="press-animated"
/>
</template> </template>
</Dialog> </AppDialog>
</div> </div>
</template> </template>

View File

@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useToast } from 'primevue/usetoast'; import AppButton from '@/components/app/AppButton.vue';
import ToggleSwitch from 'primevue/toggleswitch'; import AppSwitch from '@/components/app/AppSwitch.vue';
import Button from 'primevue/button'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import MailIcon from '@/components/icons/MailIcon.vue'; import MailIcon from '@/components/icons/MailIcon.vue';
import BellIcon from '@/components/icons/BellIcon.vue'; import BellIcon from '@/components/icons/BellIcon.vue';
import SendIcon from '@/components/icons/SendIcon.vue'; import SendIcon from '@/components/icons/SendIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue'; import TelegramIcon from '@/components/icons/TelegramIcon.vue';
const toast = useToast(); const toast = useAppToast();
const notificationSettings = ref({ const notificationSettings = ref({
email: true, email: true,
@@ -88,14 +89,16 @@ const handleSave = async () => {
Choose how you want to receive notifications and updates. Choose how you want to receive notifications and updates.
</p> </p>
</div> </div>
<Button <AppButton
label="Save Changes" size="sm"
icon="pi pi-check"
size="small"
:loading="saving" :loading="saving"
@click="handleSave" @click="handleSave"
class="press-animated" >
/> <template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
</AppButton>
</div> </div>
<!-- Content --> <!-- Content -->
@@ -116,7 +119,7 @@ const handleSave = async () => {
<p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p> <p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p>
</div> </div>
</div> </div>
<ToggleSwitch v-model="notificationSettings[type.key]" /> <AppSwitch v-model="notificationSettings[type.key]" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useToast } from 'primevue/usetoast'; import AppButton from '@/components/app/AppButton.vue';
import ToggleSwitch from 'primevue/toggleswitch'; import AppSwitch from '@/components/app/AppSwitch.vue';
import Button from 'primevue/button'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import PlayIcon from '@/components/icons/PlayIcon.vue'; import PlayIcon from '@/components/icons/PlayIcon.vue';
import RepeatIcon from '@/components/icons/RepeatIcon.vue'; import RepeatIcon from '@/components/icons/RepeatIcon.vue';
import VolumeOffIcon from '@/components/icons/VolumeOffIcon.vue'; import VolumeOffIcon from '@/components/icons/VolumeOffIcon.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue'; import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import ImageIcon from '@/components/icons/ImageIcon.vue'; import ImageIcon from '@/components/icons/ImageIcon.vue';
const toast = useToast(); const toast = useAppToast();
const playerSettings = ref({ const playerSettings = ref({
autoplay: true, autoplay: true,
@@ -102,14 +103,16 @@ const settingsItems = [
Configure default video player behavior and features. Configure default video player behavior and features.
</p> </p>
</div> </div>
<Button <AppButton
label="Save Changes" size="sm"
icon="pi pi-check"
size="small"
:loading="saving" :loading="saving"
@click="handleSave" @click="handleSave"
class="press-animated" >
/> <template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
</AppButton>
</div> </div>
<!-- Content --> <!-- Content -->
@@ -130,7 +133,7 @@ const settingsItems = [
<p class="text-xs text-foreground/60 mt-0.5">{{ item.description }}</p> <p class="text-xs text-foreground/60 mt-0.5">{{ item.description }}</p>
</div> </div>
</div> </div>
<ToggleSwitch v-model="playerSettings[item.key]" /> <AppSwitch v-model="playerSettings[item.key]" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,18 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useToast } from 'primevue/usetoast'; import AppButton from '@/components/app/AppButton.vue';
import { ref } from 'vue'; import AppDialog from '@/components/app/AppDialog.vue';
import InputText from 'primevue/inputtext'; import AppInput from '@/components/app/AppInput.vue';
import IconField from 'primevue/iconfield'; import AppSwitch from '@/components/app/AppSwitch.vue';
import InputIcon from 'primevue/inputicon'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import ToggleSwitch from 'primevue/toggleswitch';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import LockIcon from '@/components/icons/LockIcon.vue'; import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue'; import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { ref } from 'vue';
const auth = useAuthStore(); const auth = useAuthStore();
const toast = useToast(); const toast = useAppToast();
// 2FA state // 2FA state
const twoFactorEnabled = ref(false); const twoFactorEnabled = ref(false);
@@ -219,7 +218,7 @@ const disconnectTelegram = async () => {
</p> </p>
</div> </div>
</div> </div>
<ToggleSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" /> <AppSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
</div> </div>
<!-- Change Password --> <!-- Change Password -->
@@ -235,12 +234,9 @@ const disconnectTelegram = async () => {
<p class="text-xs text-foreground/60 mt-0.5">Update your account password</p> <p class="text-xs text-foreground/60 mt-0.5">Update your account password</p>
</div> </div>
</div> </div>
<Button <AppButton size="sm" @click="openChangePassword">
label="Change Password" Change Password
@click="openChangePassword" </AppButton>
size="small"
class="press-animated"
/>
</div> </div>
<!-- Email Connection --> <!-- Email Connection -->
@@ -277,31 +273,30 @@ const disconnectTelegram = async () => {
</p> </p>
</div> </div>
</div> </div>
<Button <AppButton
v-if="telegramConnected" v-if="telegramConnected"
label="Disconnect" variant="danger"
size="small" size="sm"
text
severity="danger"
@click="disconnectTelegram" @click="disconnectTelegram"
class="press-animated" >
/> Disconnect
<Button </AppButton>
<AppButton
v-else v-else
label="Connect" size="sm"
size="small"
@click="connectTelegram" @click="connectTelegram"
class="press-animated" >
/> Connect
</AppButton>
</div> </div>
</div> </div>
<!-- 2FA Setup Dialog --> <!-- 2FA Setup Dialog -->
<Dialog <AppDialog
v-model:visible="twoFactorDialogVisible" :visible="twoFactorDialogVisible"
modal @update:visible="twoFactorDialogVisible = $event"
header="Enable Two-Factor Authentication" title="Enable Two-Factor Authentication"
:style="{ width: '26rem' }" maxWidthClass="max-w-md"
> >
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-foreground/70"> <p class="text-sm text-foreground/70">
@@ -329,40 +324,35 @@ const disconnectTelegram = async () => {
<!-- Verification Code Input --> <!-- Verification Code Input -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label> <label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<InputText <AppInput
id="twoFactorCode" id="twoFactorCode"
v-model="twoFactorCode" v-model="twoFactorCode"
placeholder="Enter 6-digit code" placeholder="Enter 6-digit code"
maxlength="6" :maxlength="6"
class="w-full"
/> />
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<Button <AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
label="Cancel" Cancel
text </AppButton>
severity="secondary" <AppButton size="sm" @click="confirmTwoFactor">
@click="twoFactorDialogVisible = false" <template #icon>
class="press-animated" <CheckIcon class="w-4 h-4" />
/> </template>
<Button Verify & Enable
label="Verify & Enable" </AppButton>
@click="confirmTwoFactor"
class="press-animated"
/>
</div> </div>
</template> </template>
</Dialog> </AppDialog>
<!-- Change Password Dialog --> <!-- Change Password Dialog -->
<Dialog <AppDialog
:visible="changePasswordDialogVisible" :visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event" @update:visible="changePasswordDialogVisible = $event"
modal title="Change Password"
header="Change Password" maxWidthClass="max-w-md"
:style="{ width: '26rem' }"
> >
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-foreground/70"> <p class="text-sm text-foreground/70">
@@ -377,72 +367,70 @@ const disconnectTelegram = async () => {
<!-- Current Password --> <!-- Current Password -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label> <label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<IconField> <AppInput
<InputIcon> id="currentPassword"
v-model="currentPassword"
type="password"
placeholder="Enter current password"
>
<template #prefix>
<LockIcon class="w-5 h-5" /> <LockIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="currentPassword"
v-model="currentPassword"
type="password"
placeholder="Enter current password"
class="w-full"
/>
</IconField>
</div> </div>
<!-- New Password --> <!-- New Password -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label> <label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<IconField> <AppInput
<InputIcon> id="newPassword"
v-model="newPassword"
type="password"
placeholder="Enter new password"
>
<template #prefix>
<LockIcon class="w-5 h-5" /> <LockIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="newPassword"
v-model="newPassword"
type="password"
placeholder="Enter new password"
class="w-full"
/>
</IconField>
</div> </div>
<!-- Confirm Password --> <!-- Confirm Password -->
<div class="grid gap-2"> <div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label> <label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<IconField> <AppInput
<InputIcon> id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
>
<template #prefix>
<LockIcon class="w-5 h-5" /> <LockIcon class="w-5 h-5" />
</InputIcon> </template>
<InputText </AppInput>
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
class="w-full"
/>
</IconField>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<Button <AppButton
label="Cancel" variant="secondary"
text size="sm"
severity="secondary"
@click="changePasswordDialogVisible = false"
:disabled="changePasswordLoading" :disabled="changePasswordLoading"
class="press-animated" @click="changePasswordDialogVisible = false"
/> >
<Button Cancel
label="Change Password" </AppButton>
@click="changePassword" <AppButton
size="sm"
:loading="changePasswordLoading" :loading="changePasswordLoading"
class="press-animated" @click="changePassword"
/> >
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Change Password
</AppButton>
</div> </div>
</template> </template>
</Dialog> </AppDialog>
</div> </div>
</template> </template>