develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
21 changed files with 1137 additions and 576 deletions
Showing only changes of commit 77ece5224d - Show all commits

28
components.d.ts vendored
View File

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

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

View File

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

View File

@@ -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']"
@update:model-value="emit('update:email', $event|| '')"
/>
</IconField>
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
@update:model-value="emit('update:email', $event || '')"
>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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