feat: enhance settings pages with save functionality and UI improvements

- Added save functionality with toast notifications in NotificationSettings.vue and PlayerSettings.vue.
- Improved layout and styling in NotificationSettings.vue and PlayerSettings.vue for better user experience.
- Refactored PlayerSettings.vue to use a dynamic settingsItems array for rendering toggle switches.
- Updated SecurityNConnected.vue to enhance security settings UI, including two-factor authentication and connected accounts management.
- Introduced dialogs for changing passwords and enabling two-factor authentication with improved UX.
- Added scrollbar-gutter CSS property to prevent layout shifts when dialogs open in uno.config.ts.
This commit is contained in:
2026-03-02 03:34:47 +07:00
parent cd9aab8979
commit 16caa9281b
14 changed files with 872 additions and 808 deletions

View File

@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(bun run build)" "Bash(bun run build)",
"mcp__ide__getDiagnostics"
] ]
} }
} }

10
components.d.ts vendored
View File

@@ -44,14 +44,18 @@ 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'] 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']
@@ -70,6 +74,7 @@ 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']
@@ -127,14 +132,18 @@ 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 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']
@@ -153,6 +162,7 @@ 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']

View File

@@ -61,12 +61,10 @@ import { useRoute } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
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 ActivityIcon from '@/components/icons/ActivityIcon.vue';
import AlertTriangle from '@/components/icons/AlertTriangle.vue'; import AlertTriangle from '@/components/icons/AlertTriangle.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue'; import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import Bell from '@/components/icons/Bell.vue'; import Bell from '@/components/icons/Bell.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue';
import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue'; import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue';
import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue'; import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import CheckIcon from '@/components/icons/CheckIcon.vue';
const props = defineProps<{
plans: ModelPlan[];
isLoading: boolean;
currentPlanId?: string;
subscribingPlanId?: string | null;
}>();
const emit = defineEmits<{
(e: 'subscribe', plan: ModelPlan): void;
}>();
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDuration = (seconds?: number) => {
if (!seconds) return '0 mins';
return `${Math.floor(seconds / 60)} mins`;
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Available Plans</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Choose the plan that best fits your needs.
</p>
</div>
<div class="p-6">
<!-- Loading State -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in plans"
:key="plan.id"
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
>
<div class="mb-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">${{ plan.price }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatBytes(plan.storage_limit || 0) }} Storage
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatDuration(plan.duration_limit) }} Max Duration
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ plan.upload_limit }} Uploads / day
</li>
</ul>
<button
:disabled="!!subscribingPlanId || plan.id === currentPlanId"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
plan.id === currentPlanId
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
: subscribingPlanId === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
]"
@click="emit('subscribe', plan)"
>
{{ plan.id === currentPlanId ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade') }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
defineProps<{
currentPlan?: ModelPlan;
}>();
defineEmits<{
(e: 'manage'): void;
}>();
</script>
<template>
<div class="bg-surface border border-border rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Current Plan</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your current subscription and billing information.
</p>
</div>
<div class="p-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-7 h-7 text-primary" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ currentPlan?.name || 'Standard Plan' }}</h3>
<p class="text-sm text-foreground/60">${{ currentPlan?.price || 0 }}/month</p>
</div>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-success/10 text-success">
Active
</span>
</div>
</div>
</div>
</template>

View File

@@ -1,97 +0,0 @@
<script setup lang="ts">
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
interface PaymentHistoryItem {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
}
defineProps<{
history: PaymentHistoryItem[];
}>();
const emit = defineEmits<{
(e: 'download', item: PaymentHistoryItem): void;
}>();
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Billing History</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your past payments and invoices.
</p>
</div>
<div class="divide-y divide-border">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 px-6 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider">
<div class="col-span-3">Date</div>
<div class="col-span-2">Amount</div>
<div class="col-span-3">Plan</div>
<div class="col-span-2">Status</div>
<div class="col-span-2 text-right">Invoice</div>
</div>
<!-- Empty State -->
<div v-if="history.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>No payment history found.</p>
</div>
<!-- Table Rows -->
<div
v-for="item in history"
:key="item.id"
class="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-muted/30 transition-all"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">${{ item.amount }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
</div>
<div class="col-span-2">
<span
:class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`"
>
{{ capitalize(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
@click="emit('download', item)"
>
<DownloadIcon class="w-4 h-4" />
<span>Download</span>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,83 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
const props = defineProps<{
storageUsed: number;
storageLimit: number;
uploadsUsed: number;
uploadsLimit: number;
}>();
const storagePercentage = computed(() =>
Math.min(Math.round((props.storageUsed / props.storageLimit) * 100), 100)
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((props.uploadsUsed / props.uploadsLimit) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Usage Statistics</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your current resource usage and limits.
</p>
</div>
<div class="p-6 space-y-6">
<!-- Storage -->
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-4 h-4 text-accent" />
</div>
<span class="text-sm font-medium text-foreground">Storage</span>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</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>
<p class="text-xs text-foreground/60 mt-2">
{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used
</p>
</div>
<!-- Uploads -->
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-4 h-4 text-info" />
</div>
<span class="text-sm font-medium text-foreground">Monthly Uploads</span>
</div>
<span class="text-sm font-semibold text-foreground">{{ uploadsPercentage }}%</span>
</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>
<p class="text-xs text-foreground/60 mt-2">
{{ uploadsUsed }} of {{ uploadsLimit }} uploads
</p>
</div>
</div>
</div>
</template>

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
const props = defineProps<{
balance: number;
}>();
const emit = defineEmits<{
(e: 'topup', amount: number): void;
}>();
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
const processTopup = async () => {
if (!topupAmount.value || topupAmount.value < 1) {
return;
}
topupLoading.value = true;
try {
emit('topup', topupAmount.value);
topupDialogVisible.value = false;
topupAmount.value = null;
} finally {
topupLoading.value = false;
}
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Wallet Balance</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your current wallet balance for subscriptions and services.
</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-md hover:bg-primary/90 transition-all press-animated"
@click="openTopupDialog"
>
<PlusIcon class="w-4 h-4" />
Top Up
</button>
</div>
<div class="p-6">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<CoinsIcon class="w-8 h-8 text-primary" />
</div>
<div>
<p class="text-sm text-foreground/60">Current Balance</p>
<p class="text-3xl font-bold text-primary">${{ balance.toFixed(2) }}</p>
</div>
</div>
</div>
<!-- Top-up Dialog -->
<Teleport to="body">
<Transition name="dialog">
<div v-if="topupDialogVisible" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
@click="topupDialogVisible = false"
></div>
<!-- Dialog -->
<div
class="relative bg-surface border border-border rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-foreground">Top Up Wallet</h3>
<button
class="text-foreground/60 hover:text-foreground transition-colors"
@click="topupDialogVisible = false"
>
<XIcon class="w-5 h-5" />
</button>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<p class="text-sm text-foreground/70">
Select an amount or enter a custom amount to add to your wallet.
</p>
<!-- Preset Amounts -->
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in topupPresets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
topupAmount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="selectPreset(preset)"
>
${{ preset }}
</button>
</div>
<!-- Custom Amount -->
<div class="space-y-2">
<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>
<input
v-model.number="topupAmount"
type="number"
placeholder="Enter amount"
class="flex-1 px-3 py-2 bg-surface border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
min="1"
step="1"
/>
</div>
</div>
<!-- Info -->
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>Minimum top-up amount is $1. Funds will be added to your wallet immediately after payment.</p>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 px-6 py-4 border-t border-border">
<button
class="px-4 py-2 text-sm font-medium text-foreground/70 hover:text-foreground transition-colors"
@click="topupDialogVisible = false"
:disabled="topupLoading"
>
Cancel
</button>
<button
class="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-md hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="processTopup"
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
>
{{ topupLoading ? 'Processing...' : 'Proceed to Payment' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.2s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
.dialog-enter-active .relative,
.dialog-leave-active .relative {
transition: transform 0.2s ease;
}
.dialog-enter-from .relative,
.dialog-leave-to .relative {
transform: scale(0.95) translateY(-10px);
}
</style>

View File

@@ -4,22 +4,32 @@ import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada'; import { useQuery } from '@pinia/colada';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import WalletBalanceCard from '../components/WalletBalanceCard.vue'; import Button from 'primevue/button';
import CurrentPlanCard from '../components/CurrentPlanCard.vue'; import Dialog from 'primevue/dialog';
import UsageStatsCard from '../components/UsageStatsCard.vue'; import InputText from 'primevue/inputtext';
import AvailablePlansCard from '../components/AvailablePlansCard.vue'; import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import PaymentHistoryCard from '../components/PaymentHistoryCard.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';
const toast = useToast(); const toast = useToast();
const auth = useAuthStore(); const auth = useAuthStore();
const { data, isPending, isLoading, refresh } = useQuery({ const { data, isPending, isLoading } = useQuery({
key: () => ['payments-and-plans'], key: () => ['payments-and-plans'],
query: () => client.plans.plansList(), query: () => client.plans.plansList(),
}); });
const subscribing = ref<string | null>(null); const subscribing = ref<string | null>(null);
// Top-up state
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
// Mock Payment History Data // Mock Payment History Data
const paymentHistory = ref([ const paymentHistory = ref([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' }, { id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
@@ -28,14 +38,14 @@ const paymentHistory = ref([
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' }, { id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]); ]);
// Computed Usage (Mock if not in store) // Computed Usage (from user data)
const storageUsed = computed(() => auth.user?.storage_used || 0); const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240); const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12); const uploadsUsed = ref(12);
const uploadsLimit = ref(50); const uploadsLimit = ref(50);
// Wallet balance (from user data or mock) // Wallet balance (from user data or mock)
const walletBalance = computed(() => 0); const walletBalance = computed(() => auth.user?.wallet_balance || 0);
const currentPlanId = computed(() => { const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id; if (auth.user?.plan_id) return auth.user.plan_id;
@@ -48,6 +58,42 @@ const currentPlan = computed(() => {
return data.value.data.data.plans.find(p => p.id === currentPlanId.value); return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
}); });
// Percentages
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDuration = (seconds?: number) => {
if (!seconds) return '0 mins';
return `${Math.floor(seconds / 60)} mins`;
};
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const subscribe = async (plan: ModelPlan) => { const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return; if (!plan.id) return;
subscribing.value = plan.id; subscribing.value = plan.id;
@@ -85,8 +131,9 @@ const subscribe = async (plan: ModelPlan) => {
}; };
const handleTopup = async (amount: number) => { const handleTopup = async (amount: number) => {
topupLoading.value = true;
try { try {
// Simulate API call for top-up // TODO: Add API endpoint for top-up
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({ toast.add({
@@ -95,6 +142,8 @@ const handleTopup = async (amount: number) => {
detail: `$${amount} has been added to your wallet.`, detail: `$${amount} has been added to your wallet.`,
life: 3000 life: 3000
}); });
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (e: any) { } catch (e: any) {
toast.add({ toast.add({
severity: 'error', severity: 'error',
@@ -102,6 +151,8 @@ const handleTopup = async (amount: number) => {
detail: e.message || 'Failed to process top-up.', detail: e.message || 'Failed to process top-up.',
life: 5000 life: 5000
}); });
} finally {
topupLoading.value = false;
} }
}; };
@@ -122,38 +173,314 @@ const handleDownloadInvoice = (item: typeof paymentHistory.value[number]) => {
}); });
}, 1500); }, 1500);
}; };
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="bg-surface border border-border rounded-lg">
<WalletBalanceCard <!-- Header -->
:balance="walletBalance" <div class="px-6 py-4 border-b border-border">
@topup="handleTopup" <h2 class="text-base font-semibold text-foreground">Billing & Plans</h2>
/> <p class="text-sm text-foreground/60 mt-0.5">
Manage your subscription, wallet, and billing information.
</p>
</div>
<CurrentPlanCard <!-- Content -->
:current-plan="currentPlan" <div class="divide-y divide-border">
@manage="() => {}" <!-- Wallet Balance -->
/> <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">
<CoinsIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Wallet Balance</p>
<p class="text-xs text-foreground/60 mt-0.5">
Current balance: ${{ walletBalance.toFixed(2) }}
</p>
</div>
</div>
<Button
label="Top Up"
icon="pi pi-plus"
size="small"
@click="openTopupDialog"
class="press-animated"
/>
</div>
<UsageStatsCard <!-- Current Plan -->
:storage-used="storageUsed" <div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
:storage-limit="storageLimit" <div class="flex items-center gap-4">
:uploads-used="uploadsUsed" <div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
:uploads-limit="uploadsLimit" <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>
<AvailablePlansCard <!-- Storage Usage -->
:plans="data?.data?.data.plans || []" <div class="px-6 py-4 hover:bg-muted/30 transition-all">
:is-loading="isLoading" <div class="flex items-center gap-4 mb-3">
:current-plan-id="currentPlanId" <div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
:subscribing-plan-id="subscribing" <ActivityIcon class="w-5 h-5 text-accent" />
@subscribe="subscribe" </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>
<PaymentHistoryCard <!-- Uploads Usage -->
:history="paymentHistory" <div class="px-6 py-4 hover:bg-muted/30 transition-all">
@download="handleDownloadInvoice" <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">
<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">Available Plans</p>
<p class="text-xs text-foreground/60 mt-0.5">
Choose the plan that best fits your needs
</p>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in data?.data?.data.plans || []"
:key="plan.id"
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
>
<div class="mb-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">${{ plan.price }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatBytes(plan.storage_limit || 0) }} Storage
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatDuration(plan.duration_limit) }} Max Duration
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ plan.upload_limit }} Uploads / day
</li>
</ul>
<button
:disabled="!!subscribing || plan.id === currentPlanId"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
plan.id === currentPlanId
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
: subscribing === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
]"
@click="subscribe(plan)"
>
{{ plan.id === currentPlanId ? 'Current Plan' : (subscribing === plan.id ? 'Processing...' : 'Upgrade') }}
</button>
</div>
</div>
</div>
<!-- Payment History -->
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<DownloadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Payment History</p>
<p class="text-xs text-foreground/60 mt-0.5">
Your past payments and invoices
</p>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
<div class="col-span-3">Date</div>
<div class="col-span-2">Amount</div>
<div class="col-span-3">Plan</div>
<div class="col-span-2">Status</div>
<div class="col-span-2 text-right">Invoice</div>
</div>
<!-- Empty State -->
<div v-if="paymentHistory.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>No payment history found.</p>
</div>
<!-- Table Rows -->
<div
v-for="item in paymentHistory"
:key="item.id"
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">${{ item.amount }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
</div>
<div class="col-span-2">
<span
:class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`"
>
{{ capitalize(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
@click="handleDownloadInvoice(item)"
>
<DownloadIcon class="w-4 h-4" />
<span>Download</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Top-up Dialog -->
<Dialog
v-model:visible="topupDialogVisible"
modal
header="Top Up Wallet"
:style="{ width: '28rem' }"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Select an amount or enter a custom amount to add to your wallet.
</p>
<!-- Preset Amounts -->
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in topupPresets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
topupAmount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="selectPreset(preset)"
>
${{ preset }}
</button>
</div>
<!-- Custom Amount -->
<div class="space-y-2">
<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
v-model.number="topupAmount"
type="number"
placeholder="Enter amount"
class="flex-1"
min="1"
step="1"
/>
</div>
</div>
<!-- Info -->
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>Minimum top-up amount is $1. Funds will be added to your wallet immediately after payment.</p>
</div>
</div>
<template #footer>
<Button
label="Cancel"
text
severity="secondary"
@click="topupDialogVisible = false"
:disabled="topupLoading"
class="press-animated"
/>
<Button
label="Proceed to Payment"
@click="handleTopup(topupAmount || 0)"
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
:loading="topupLoading"
class="press-animated"
/>
</template>
</Dialog>
</div> </div>
</template> </template>

View File

@@ -2,6 +2,7 @@
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button'; import Button from 'primevue/button';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
const toast = useToast(); const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
@@ -24,6 +25,25 @@ const handleDeleteAccount = () => {
} }
}); });
}; };
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',
summary: 'Data cleared',
detail: 'All your data has been permanently deleted.',
life: 5000
});
}
});
};
</script> </script>
<template> <template>
@@ -36,57 +56,70 @@ const handleDeleteAccount = () => {
</p> </p>
</div> </div>
<!-- Danger Zone Content --> <!-- Content -->
<div class="p-6"> <div class="divide-y divide-border">
<div class="border-2 border-danger/30 rounded-md bg-danger/5"> <!-- Delete Account -->
<!-- Delete Account --> <div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-start justify-between px-5 py-4 border-b border-danger/20"> <div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<AlertTriangleIcon class="w-5 h-5 text-danger" />
</div>
<div> <div>
<h3 class="text-sm font-semibold text-foreground">Delete Account</h3> <p class="text-sm font-medium text-foreground">Delete Account</p>
<p class="text-xs text-foreground/60 mt-1"> <p class="text-xs text-foreground/60 mt-0.5">
Permanently delete your account and all associated data. Permanently delete your account and all associated data.
</p> </p>
</div> </div>
<Button
label="Delete Account"
icon="pi pi-trash"
severity="danger"
size="small"
@click="handleDeleteAccount"
class="press-animated"
/>
</div> </div>
<Button
label="Delete Account"
icon="pi pi-trash"
severity="danger"
size="small"
@click="handleDeleteAccount"
class="press-animated"
/>
</div>
<!-- Clear All Data --> <!-- Clear All Data -->
<div class="flex items-start justify-between px-5 py-4"> <div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</div>
<div> <div>
<h3 class="text-sm font-semibold text-foreground">Clear All Data</h3> <p class="text-sm font-medium text-foreground">Clear All Data</p>
<p class="text-xs text-foreground/60 mt-1"> <p class="text-xs text-foreground/60 mt-0.5">
Remove all your videos, playlists, and activity history. Remove all your videos, playlists, and activity history.
</p> </p>
</div> </div>
<Button
label="Clear Data"
icon="pi pi-eraser"
severity="danger"
size="small"
outlined
class="press-animated"
/>
</div> </div>
<Button
label="Clear Data"
icon="pi pi-eraser"
severity="danger"
size="small"
outlined
@click="handleClearData"
class="press-animated"
/>
</div> </div>
</div>
<!-- Warning Banner --> <!-- Warning Banner -->
<div class="mt-4 border border-warning/30 bg-warning/5 rounded-md p-4"> <div class="mx-6 mt-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> <i class="pi pi-exclamation-triangle text-warning text-sm mt-0.5"></i>
<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>
These actions are permanent and cannot be undone. These actions are permanent and cannot be undone.
Make sure you have backed up any important data before proceeding. Make sure you have backed up any important data before proceeding.
</p> </p>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
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 notificationSettings = ref({ const notificationSettings = ref({
email: true, email: true,
push: true, push: true,
@@ -12,6 +17,8 @@ const notificationSettings = ref({
telegram: false, telegram: false,
}); });
const saving = ref(false);
const notificationTypes = [ const notificationTypes = [
{ {
key: 'email' as const, key: 'email' as const,
@@ -47,19 +54,48 @@ const notificationTypes = [
}, },
]; ];
defineEmits<{ const handleSave = async () => {
save: []; saving.value = true;
}>(); try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.add({
severity: 'success',
summary: 'Settings Saved',
detail: 'Your notification settings have been saved.',
life: 3000
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Save Failed',
detail: e.message || 'Failed to save settings.',
life: 5000
});
} finally {
saving.value = false;
}
};
</script> </script>
<template> <template>
<div class="bg-surface border border-border rounded-lg"> <div class="bg-surface border border-border rounded-lg">
<!-- Header --> <!-- Header -->
<div class="px-6 py-4 border-b border-border"> <div class="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 class="text-base font-semibold text-foreground">Notifications</h2> <div>
<p class="text-sm text-foreground/60 mt-0.5"> <h2 class="text-base font-semibold text-foreground">Notifications</h2>
Choose how you want to receive notifications and updates. <p class="text-sm text-foreground/60 mt-0.5">
</p> Choose how you want to receive notifications and updates.
</p>
</div>
<Button
label="Save Changes"
icon="pi pi-check"
size="small"
:loading="saving"
@click="handleSave"
class="press-animated"
/>
</div> </div>
<!-- Content --> <!-- Content -->

View File

@@ -1,12 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
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';
import WifiIcon from '@/components/icons/WifiIcon.vue';
import MonitorIcon from '@/components/icons/MonitorIcon.vue'; const toast = useToast();
const playerSettings = ref({ const playerSettings = ref({
autoplay: true, autoplay: true,
@@ -18,154 +21,116 @@ const playerSettings = ref({
Chromecast: false, Chromecast: false,
}); });
defineEmits<{ const saving = ref(false);
save: [];
}>(); const handleSave = async () => {
saving.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.add({
severity: 'success',
summary: 'Settings Saved',
detail: 'Your player settings have been saved.',
life: 3000
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Save Failed',
detail: e.message || 'Failed to save settings.',
life: 5000
});
} finally {
saving.value = false;
}
};
const settingsItems = [
{
key: 'autoplay' as const,
title: 'Autoplay',
description: 'Automatically start videos when loaded',
svg: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 404"><path d="M26 45v314c0 10 9 19 19 19 5 0 9-2 13-5l186-157c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19z" class="fill-primary/30"/><path d="M26 359c0 11 9 19 19 19 5 0 9-2 13-4l186-158c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19v314zm-16 0V45c0-19 16-35 35-35 8 0 17 3 23 8l186 158c8 6 12 16 12 26s-4 20-12 26L68 386c-6 5-15 8-23 8-19 0-35-16-35-35zM378 18v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8zm144 0v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8z" class="fill-primary"/></svg>`,
},
{
key: 'loop' as const,
title: 'Loop',
description: 'Repeat video when it ends',
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497 496"><path d="M80 248c0 30 8 58 21 82h65c8 0 14 6 14 14s-6 14-14 14h-45c31 36 76 58 127 58 93 0 168-75 168-168 0-30-8-58-21-82h-65c-8 0-14-6-14-14s6-14 14-14h45c-31-35-76-58-127-58-93 0-168 75-168 168z" class="fill-primary/30"/><path d="M70 358c37 60 103 100 179 100 116 0 210-94 210-210 0-8 6-14 14-14 7 0 14 6 14 14 0 131-107 238-238 238-82 0-154-41-197-104v90c0 8-6 14-14 14s-14-6-14-14V344c0-8 6-14 14-14h128c8 0 14 6 14 14s-6 14-14 14H70zm374-244V24c0-8 6-14 14-14s14 6 14 14v128c0 8-6 14-14 14H330c-8 0-14-6-14-14s6-14 14-14h96C389 78 323 38 248 38 132 38 38 132 38 248c0 8-7 14-14 14-8 0-14-6-14-14C10 117 116 10 248 10c81 0 153 41 196 104z" class="fill-primary"/></svg>`,
},
{
key: 'muted' as const,
title: 'Muted',
description: 'Start videos with sound muted',
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 549 468"><path d="M26 186v96c0 18 14 32 32 32h64c4 0 8 2 11 5l118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32z" class="fill-primary/30"/><path d="m133 319 118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32v96c0 18 14 32 32 32h64c4 0 8 2 11 5zM58 138h64L240 20c7-6 15-10 24-10 19 0 34 15 34 34v380c0 19-15 34-34 34-9 0-17-4-24-10L122 330H58c-26 0-48-21-48-48v-96c0-26 22-48 48-48zm322 18c3-3 9-3 12 0l66 67 66-67c3-3 8-3 12 0 3 3 3 9 0 12l-67 66 67 66c3 3 3 8 0 12-4 3-9 3-12 0l-66-67-66 67c-3 3-9 3-12 0-3-4-3-9 0-12l67-66-67-66c-3-3-3-9 0-12z" class="fill-primary"/></svg>`,
},
{
key: 'showControls' as const,
title: 'Show Controls',
description: 'Display player controls (play, pause, volume)',
svg: `<svg class="h6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M26 74v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74c0-26-21-48-48-48H74c-26 0-48 22-48 48zm48 72c0-4 4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8zm0 176c0-4 4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8s-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8z" class="fill-primary/30"/><path d="M442 74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74zm16 320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h320c35 0 64 29 64 64v320zm-64-72c0 4-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8zm-112 0v32c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16v32zm104-184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184zm-232-24v64c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16z" class="fill-primary"/></svg>`
},
{
key: 'pip' as const,
title: 'Picture in Picture',
description: 'Enable Picture-in-Picture mode',
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v112H314c-53 0-96 43-96 96v160H74c-26 0-48-21-48-48V74z" class="fill-primary/30"/><path d="M458 10c35 0 64 29 64 64v112h-16V74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h144v16H68c-31-3-55-27-58-57V74c0-33 25-60 58-64h390zm16 224c27 0 48 22 48 48v133c-3 24-23 43-48 43H309c-22-2-40-20-43-43V282c0-26 22-48 48-48h160zm-160 16c-18 0-32 14-32 32v128c0 18 14 32 32 32h160c18 0 32-14 32-32V282c0-18-14-32-32-32H314z" class="fill-primary"/></svg>`
},
{
key: 'airplay' as const,
title: 'AirPlay',
description: 'Allow streaming to Apple devices via AirPlay',
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 436"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v224c0 26-21 47-47 48-45-45-91-91-136-137-32-31-82-31-114 0-45 46-91 91-136 137-26-1-47-22-47-48V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v224c0 26 21 47 47 48l-14 14c-28-7-49-32-49-62V74c0-35 29-64 64-64h384c35 0 64 29 64 64v224c0 30-21 55-49 62l-14-14c26-1 47-22 47-48V74c0-26-21-48-48-48zM138 410h256c7 0 12-4 15-10 2-6 1-13-4-17L277 255c-6-6-16-6-22 0L127 383c-5 4-6 11-4 17 3 6 9 10 15 10zm279-39c9 10 12 23 7 35s-17 20-30 20H138c-13 0-25-8-30-20s-2-25 7-35l128-128c13-12 33-12 46 0l128 128z" class="fill-primary"/></svg>`,
},
{
key: 'Chromecast' as const,
title: 'Chromecast',
description: 'Allow casting to Chromecast devices',
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v320c0 27-21 48-48 48H314v-9c0-154-125-279-279-279h-9V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v80H10V74c0-35 29-64 64-64h384c35 0 64 29 64 64v320c0 35-29 64-64 64H314v-16h144c27 0 48-21 48-48V74c0-26-21-48-48-48zM18 202c137 0 248 111 248 248 0 4-4 8-8 8s-8-4-8-8c0-128-104-232-232-232-4 0-8-4-8-8s4-8 8-8zm40 224c0-9-7-16-16-16s-16 7-16 16 7 16 16 16 16-7 16-16zm-48 0c0-18 14-32 32-32s32 14 32 32-14 32-32 32-32-14-32-32zm0-120c0-4 4-8 8-8 84 0 152 68 152 152 0 4-4 8-8 8s-8-4-8-8c0-75-61-136-136-136-4 0-8-4-8-8z" class="fill-primary"/></svg>`,
},
];
</script> </script>
<template> <template>
<div class="bg-surface border border-border rounded-lg"> <div class="bg-surface border border-border rounded-lg">
<!-- Header --> <!-- Header -->
<div class="px-6 py-4 border-b border-border"> <div class="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 class="text-base font-semibold text-foreground">Player Settings</h2> <div>
<p class="text-sm text-foreground/60 mt-0.5"> <h2 class="text-base font-semibold text-foreground">Player Settings</h2>
Configure default video player behavior and features. <p class="text-sm text-foreground/60 mt-0.5">
</p> Configure default video player behavior and features.
</p>
</div>
<Button
label="Save Changes"
icon="pi pi-check"
size="small"
:loading="saving"
@click="handleSave"
class="press-animated"
/>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="divide-y divide-border"> <div class="divide-y divide-border">
<div <div
v-for="item in settingsItems"
:key="item.key"
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all" 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="flex items-center gap-4">
<div <div
class=":uno: w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0" :class="`:uno: w-10 h-10 rounded-md flex items-center justify-center shrink-0 bg-primary/10 text-primary`"
> >
<PlayIcon class="text-primary w-5 h-5" /> <span v-html="item.svg" />
</div> </div>
<div> <div>
<p class="text-sm font-medium text-foreground">Autoplay</p> <p class="text-sm font-medium text-foreground">{{ item.title }}</p>
<p class="text-xs text-foreground/60 mt-0.5"> <p class="text-xs text-foreground/60 mt-0.5">{{ item.description }}</p>
Automatically start videos when loaded
</p>
</div> </div>
</div> </div>
<ToggleSwitch v-model="playerSettings.autoplay" /> <ToggleSwitch v-model="playerSettings[item.key]" />
</div>
<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=":uno: w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0"
>
<RepeatIcon class="text-accent w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Loop</p>
<p class="text-xs text-foreground/60 mt-0.5">
Repeat video when it ends
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.loop" />
</div>
<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=":uno: w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0"
>
<VolumeOffIcon class="text-info w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Muted</p>
<p class="text-xs text-foreground/60 mt-0.5">
Start videos with sound muted
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.muted" />
</div>
<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=":uno: w-10 h-10 rounded-md bg-success/10 flex items-center justify-center shrink-0"
>
<SlidersIcon class="text-success w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Show Controls</p>
<p class="text-xs text-foreground/60 mt-0.5">
Display player controls (play, pause, volume)
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.showControls" />
</div>
<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=":uno: w-10 h-10 rounded-md bg-warning/10 flex items-center justify-center shrink-0"
>
<ImageIcon class="text-warning w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Picture in Picture</p>
<p class="text-xs text-foreground/60 mt-0.5">
Enable Picture-in-Picture mode
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.pip" />
</div>
<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=":uno: w-10 h-10 rounded-md bg-secondary/10 flex items-center justify-center shrink-0"
>
<WifiIcon class="text-secondary w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">AirPlay</p>
<p class="text-xs text-foreground/60 mt-0.5">
Allow streaming to Apple devices via AirPlay
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.airplay" />
</div>
<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=":uno: w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0"
>
<MonitorIcon class="text-info w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Chromecast</p>
<p class="text-xs text-foreground/60 mt-0.5">
Allow casting to Chromecast devices
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.Chromecast" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,22 +2,23 @@
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { ref } from 'vue'; import { ref } from 'vue';
import ProfileInformationCard from '../components/ProfileInformationCard.vue'; import InputText from 'primevue/inputtext';
import SecuritySettingsCard from '../components/SecuritySettingsCard.vue'; import IconField from 'primevue/iconfield';
import ConnectedAccountsCard from '../components/ConnectedAccountsCard.vue'; import InputIcon from 'primevue/inputicon';
import ToggleSwitch from 'primevue/toggleswitch';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const toast = useToast(); const toast = useToast();
// Form state
const editing = ref(false);
const username = ref('');
const email = ref('');
const saving = ref(false);
// 2FA state // 2FA state
const twoFactorEnabled = ref(false); const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false); const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
// Connected accounts state // Connected accounts state
const emailConnected = ref(true); const emailConnected = ref(true);
@@ -32,48 +33,6 @@ const confirmPassword = ref('');
const changePasswordLoading = ref(false); const changePasswordLoading = ref(false);
const changePasswordError = ref(''); const changePasswordError = ref('');
// Initialize form values
const initForm = () => {
username.value = auth.user?.username || '';
email.value = auth.user?.email || '';
emailConnected.value = !!auth.user?.email;
};
// Start editing
const startEdit = () => {
initForm();
editing.value = true;
};
// Cancel edit
const cancelEdit = () => {
editing.value = false;
};
// Save profile
const saveProfile = async () => {
saving.value = true;
try {
await auth.updateProfile({ username: username.value, email: email.value });
toast.add({
severity: 'success',
summary: 'Profile Updated',
detail: 'Your profile has been updated successfully.',
life: 3000
});
editing.value = false;
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Update Failed',
detail: e.message || 'Failed to update profile.',
life: 5000
});
} finally {
saving.value = false;
}
};
// Change password handler // Change password handler
const openChangePassword = () => { const openChangePassword = () => {
changePasswordDialogVisible.value = true; changePasswordDialogVisible.value = true;
@@ -117,9 +76,8 @@ const changePassword = async () => {
}; };
// Toggle 2FA // Toggle 2FA
const toggleTwoFactor = async () => { const handleToggle2FA = async () => {
if (!twoFactorEnabled.value) { if (!twoFactorEnabled.value) {
// Enable 2FA - generate secret and QR code
try { try {
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true; twoFactorDialogVisible.value = true;
@@ -133,7 +91,6 @@ const toggleTwoFactor = async () => {
twoFactorEnabled.value = false; twoFactorEnabled.value = false;
} }
} else { } else {
// Disable 2FA
try { try {
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
toast.add({ toast.add({
@@ -221,42 +178,271 @@ const disconnectTelegram = async () => {
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="bg-surface border border-border rounded-lg">
<SecuritySettingsCard <!-- Header -->
v-model:two-factor-enabled="twoFactorEnabled" <div class="px-6 py-4 border-b border-border">
:change-password-error="changePasswordError" <h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
:change-password-loading="changePasswordLoading" <p class="text-sm text-foreground/60 mt-0.5">
:current-password="currentPassword" Manage your security settings and connected services.
:new-password="newPassword" </p>
:confirm-password="confirmPassword" </div>
@toggle-2fa="toggleTwoFactor"
@change-password="openChangePassword"
@close-password-dialog="changePasswordDialogVisible = false"
@close-2fa-dialog="twoFactorDialogVisible = false"
@confirm-2fa="confirmTwoFactor"
@update:current-password="currentPassword = $event"
@update:new-password="newPassword = $event"
@update:confirm-password="confirmPassword = $event"
/>
<ConnectedAccountsCard <!-- Content -->
:dialog-visible="changePasswordDialogVisible" <div class="divide-y divide-border">
@update:dialog-visible="changePasswordDialogVisible = $event" <!-- Account Status -->
:error="changePasswordError" <div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
:loading="changePasswordLoading" <div class="flex items-center gap-4">
:current-password="currentPassword" <div class="w-10 h-10 rounded-md bg-success/10 flex items-center justify-center shrink-0">
:new-password="newPassword" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
:confirm-password="confirmPassword" <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
:email-connected="emailConnected" <polyline points="22 4 12 14.01 9 11.01"/>
:telegram-connected="telegramConnected" </svg>
:telegram-username="telegramUsername" </div>
@close="changePasswordDialogVisible = false" <div>
@change-password="changePassword" <p class="text-sm font-medium text-foreground">Account Status</p>
@connect-telegram="connectTelegram" <p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
@disconnect-telegram="disconnectTelegram" </div>
@update:current-password="currentPassword = $event" </div>
@update:new-password="newPassword = $event" <span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
@update:confirm-password="confirmPassword = $event" </div>
/>
<!-- Two-Factor Authentication -->
<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">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? '2FA is enabled' : 'Add an extra layer of security' }}
</p>
</div>
</div>
<ToggleSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
</div>
<!-- Change Password -->
<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">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Change Password</p>
<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"
/>
</div>
<!-- Email Connection -->
<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-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Email</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? 'Connected' : 'Not connected' }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<!-- Telegram Connection -->
<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-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Telegram</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || 'Connected') : 'Get notified via Telegram' }}
</p>
</div>
</div>
<Button
v-if="telegramConnected"
label="Disconnect"
size="small"
text
severity="danger"
@click="disconnectTelegram"
class="press-animated"
/>
<Button
v-else
label="Connect"
size="small"
@click="connectTelegram"
class="press-animated"
/>
</div>
</div>
<!-- 2FA Setup Dialog -->
<Dialog
v-model:visible="twoFactorDialogVisible"
modal
header="Enable Two-Factor Authentication"
:style="{ width: '26rem' }"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<!-- QR Code Placeholder -->
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">Secret Key:</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<InputText
id="twoFactorCode"
v-model="twoFactorCode"
placeholder="Enter 6-digit code"
maxlength="6"
class="w-full"
/>
</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"
/>
</div>
</template>
</Dialog>
<!-- Change Password Dialog -->
<Dialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
modal
header="Change Password"
:style="{ width: '26rem' }"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Enter your current password and choose a new password.
</p>
<!-- Error Message -->
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ changePasswordError }}
</div>
<!-- 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
id="currentPassword"
v-model="currentPassword"
type="password"
placeholder="Enter current password"
class="w-full"
/>
</IconField>
</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
id="newPassword"
v-model="newPassword"
type="password"
placeholder="Enter new password"
class="w-full"
/>
</IconField>
</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
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
class="w-full"
/>
</IconField>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<Button
label="Cancel"
text
severity="secondary"
@click="changePasswordDialogVisible = false"
:disabled="changePasswordLoading"
class="press-animated"
/>
<Button
label="Change Password"
@click="changePassword"
:loading="changePasswordLoading"
class="press-animated"
/>
</div>
</template>
</Dialog>
</div> </div>
</template> </template>

View File

@@ -226,6 +226,17 @@ export default defineConfig({
{ {
getCSS: (context) => { getCSS: (context) => {
return ` return `
html {
scrollbar-gutter: stable;
}
body {
scrollbar-gutter: stable !important;
}
/* Prevent layout shift when PrimeVue dialogs open */
body.p-overflow-hidden {
overflow: hidden !important;
padding-right: 0 !important;
}
:root { :root {
--font-sans: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-sans: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Playfair Display', serif, 'Times New Roman', Times, serif; --font-serif: 'Playfair Display', serif, 'Times New Roman', Times, serif;