feat(settings): add Billing, Danger Zone, Domains DNS, Notification, Player, and Security settings pages

- Implemented Billing page with wallet balance, current plan, usage stats, available plans, and payment history.
- Created Danger Zone page for account deletion and data clearing actions with confirmation prompts.
- Developed Domains DNS page for managing whitelisted domains for iframe embedding, including add and remove functionality.
- Added Notification Settings page to configure email, push, marketing, and Telegram notifications.
- Introduced Player Settings page to customize video player behavior such as autoplay, loop, and controls visibility.
- Established Security and Connected Accounts page for managing user profile, two-factor authentication, and connected accounts.
This commit is contained in:
2026-03-01 22:49:30 +07:00
parent c6924afe5b
commit cd9aab8979
65 changed files with 3150 additions and 1133 deletions

View File

@@ -24,12 +24,10 @@ const routes: RouteData[] = [
{
path: "",
component: () => import("./home/Home.vue"),
beforeEnter: (to, from, next) => {
beforeEnter: (to, from) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
return { name: "overview" };
}
},
},
@@ -48,12 +46,10 @@ const routes: RouteData[] = [
{
path: "",
component: () => import("./auth/layout.vue"),
beforeEnter: (to, from, next) => {
beforeEnter: (to, from) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
return { name: "overview" };
}
},
children: [
@@ -130,22 +126,6 @@ const routes: RouteData[] = [
// },
],
},
{
path: "payments-and-plans",
name: "payments-and-plans",
component: () => import("./plans/Plans.vue"),
meta: {
head: {
title: "Payments & Plans - Holistream",
meta: [
{
name: "description",
content: "Manage your plans and billing information.",
},
],
},
},
},
{
path: "notification",
name: "notification",
@@ -157,14 +137,99 @@ const routes: RouteData[] = [
},
},
{
path: "profile",
name: "profile",
component: () => import("./profile/Profile.vue"), // TODO: create profile page
path: "settings",
name: "settings",
component: () => import("./settings/Settings.vue"),
meta: {
head: {
title: "Profile - Holistream",
title: "Settings - Holistream",
meta: [
{
name: "description",
content: "Manage your account settings and preferences.",
},
],
},
},
redirect: '/settings/security',
children: [
{
path: "security",
name: "settings-security",
component: () => import("./settings/pages/SecurityNConnected.vue"),
meta: {
head: {
title: "Security & Connected Apps - Holistream",
},
},
},
{
path: "billing",
name: "settings-billing",
component: () => import("./settings/pages/Billing.vue"),
meta: {
head: {
title: "Billing & Plans - Holistream",
meta: [
{
name: "description",
content: "Manage your plans and billing information.",
},
],
},
},
},
{
path: "notifications",
name: "settings-notifications",
component: () => import("./settings/pages/NotificationSettings.vue"),
meta: {
head: {
title: "Notifications - Holistream",
},
},
},
{
path: "player",
name: "settings-player",
component: () => import("./settings/pages/PlayerSettings.vue"),
meta: {
head: {
title: "Player Settings - Holistream",
},
},
},
{
path: "domains",
name: "settings-domains",
component: () => import("./settings/pages/DomainsDns.vue"),
meta: {
head: {
title: "Allowed Domains - Holistream",
},
},
},
{
path: "ads",
name: "settings-ads",
component: () => import("./settings/pages/AdsVast.vue"),
meta: {
head: {
title: "Ads & VAST - Holistream",
},
},
},
{
path: "danger",
name: "settings-danger",
component: () => import("./settings/pages/DangerZone.vue"),
meta: {
head: {
title: "Danger Zone - Holistream",
},
},
},
],
},
],
},
@@ -190,18 +255,14 @@ const createAppRouter = () => {
},
});
router.beforeEach((to, from, next) => {
router.beforeEach((to, from) => {
const auth = useAuthStore();
const head = inject(headSymbol);
(head as any).push(to.meta.head || {});
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!auth.user) {
next({ name: "login" });
} else {
next();
return { name: "login" };
}
} else {
next();
}
});
return router;

View File

@@ -1,205 +0,0 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { computed, ref } from 'vue';
import CurrentPlanCard from './components/CurrentPlanCard.vue';
import EditPlanDialog from './components/EditPlanDialog.vue';
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
import PlanList from './components/PlanList.vue';
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
import UsageStatsCard from './components/UsageStatsCard.vue';
// const ahihi = defineBasicLoader('/payments-and-plans', async to => {
// return client.plans.plansList();
// })
// const { data, isLoading, reload } = ahihi();
const { data, isPending, isLoading, refresh } = useQuery({
// unique key for the query in the cache
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
})
const auth = useAuthStore();
// const plans = ref<ModelPlan[]>([]);
const subscribing = ref<string | null>(null);
const showManageDialog = ref(false);
const cancelling = ref(false);
// Mock Payment History Data
const paymentHistory = ref([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]);
// Computed Usage (Mock if not in store)
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
// Default limit 10GB if no plan
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id; // Fallback to first plan
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
});
// watch(data, (newValue) => {
// if (newValue) {
// // Handle potentially different response structures
// // Safe access to avoid SSR crash if data is null/undefined
// const plansList = newValue?.data?.data?.plans;
// if (Array.isArray(plansList)) {
// plans.value = plansList;
// }
// }
// }, { immediate: true });
const showEditDialog = ref(false);
const editingPlan = ref<ModelPlan>({});
const isSaving = ref(false);
const openEditPlan = (plan: ModelPlan) => {
editingPlan.value = { ...plan };
showEditDialog.value = true;
};
const savePlan = async (updatedPlan: ModelPlan) => {
isSaving.value = true;
try {
if (!updatedPlan.id) return;
// Optimistic update or API call
await client.request({
path: `/plans/${updatedPlan.id}`,
method: 'PUT',
body: updatedPlan
});
// Refresh plans
await refresh();
showEditDialog.value = false;
alert('Plan updated successfully');
} catch (e: any) {
console.error('Failed to update plan', e);
// Fallback: update local state if API is mocked/missing
const idx = data.value!.data.data.plans.findIndex(p => p.id === updatedPlan.id);
if (idx !== -1) {
data.value!.data.data.plans[idx] = { ...updatedPlan };
}
showEditDialog.value = false;
// alert('Note: API update failed, updated locally. ' + e.message);
} finally {
isSaving.value = false;
}
};
const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return;
subscribing.value = plan.id;
try {
await client.payments.paymentsCreate({
amount: plan.price || 0,
plan_id: plan.id
});
// Update local state mock
// In real app, we would re-fetch user profile
alert(`Successfully subscribed to ${plan.name}`);
paymentHistory.value.unshift({
id: `inv_${Date.now()}`,
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
amount: plan.price || 0,
plan: plan.name || 'Unknown',
status: 'success',
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`
});
} catch (err: any) {
console.error(err);
alert('Failed to subscribe: ' + (err.message || 'Unknown error'));
} finally {
subscribing.value = null;
}
};
const cancelSubscription = async () => {
cancelling.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
alert('Subscription has been canceled.');
showManageDialog.value = false;
} catch (e) {
alert('Failed to cancel subscription.');
} finally {
cancelling.value = false;
}
};
</script>
<template>
<div class="plans-page">
<PageHeader
title="Subscription"
description="Manage your workspace plan and usage"
:breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Subscription' }
]"
/>
<div class="content max-w-7xl mx-auto space-y-12 pb-12">
<!-- Hero Section: Current Plan & Usage -->
<div v-if="!isLoading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<CurrentPlanCard
:current-plan="currentPlan"
@manage="showManageDialog = true"
/>
<UsageStatsCard
:storage-used="storageUsed"
:storage-limit="storageLimit"
:uploads-used="uploadsUsed"
:uploads-limit="uploadsLimit"
/>
</div>
<PlanList
:plans="data?.data?.data.plans || []"
:is-loading="!!isLoading"
:current-plan-id="currentPlanId"
:subscribing-plan-id="subscribing"
:is-admin="auth.user?.role === 'admin'"
@subscribe="subscribe"
@edit="openEditPlan"
/>
<PlanPaymentHistory :history="paymentHistory" />
<ManageSubscriptionDialog
v-model:visible="showManageDialog"
:current-plan="currentPlan"
:cancelling="cancelling"
@cancel-subscription="cancelSubscription"
/>
</div>
<EditPlanDialog
v-model:visible="showEditDialog"
:plan="editingPlan"
:loading="isSaving"
@save="savePlan"
/>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
defineProps<{
currentPlan?: ModelPlan;
}>();
defineEmits<{
(e: 'manage'): void;
}>();
</script>
<template>
<div class=":uno: lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8">
<!-- Background decorations -->
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full blur-3xl opacity-20"></div>
<div class="absolute bottom-0 left-0 -mb-16 -ml-16 w-64 h-64 bg-purple-500 rounded-full blur-3xl opacity-20"></div>
<div class="relative z-10 flex flex-col h-full justify-between">
<div class="flex justify-between items-start">
<div>
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
<Tag value="Active" severity="success" class="px-3" rounded></Tag>
</div>
<div class="text-right">
<div class="text-3xl font-bold text-white">${{ currentPlan?.price || 0 }}<span class="text-lg text-gray-400 font-normal">/mo</span></div>
<p class="text-gray-400 text-sm mt-1">Next billing on Feb 24, 2026</p>
</div>
</div>
<div class="mt-8 pt-8 border-t border-gray-700/50 flex gap-4">
<Button label="Manage Subscription" severity="secondary" class="bg-white/10 border-white/10 text-white hover:bg-white/20" @click="$emit('manage')" />
</div>
</div>
</div>
</template>

View File

@@ -1,90 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Checkbox from 'primevue/checkbox';
import Dialog from 'primevue/dialog';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import { computed, ref, watch } from 'vue';
const props = defineProps<{
visible: boolean;
plan: ModelPlan;
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'save', plan: ModelPlan): void;
}>();
// Create a local copy to edit
const localPlan = ref<ModelPlan>({});
// Sync when dialog opens or plan changes
watch(() => props.plan, (newPlan) => {
localPlan.value = { ...newPlan };
}, { immediate: true });
const onSave = () => {
emit('save', localPlan.value);
};
const visibleModel = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
});
</script>
<template>
<Dialog v-model:visible="visibleModel" modal header="Edit Plan" :style="{ width: '40rem' }">
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label for="plan-name" class="text-sm font-medium text-gray-700">Name</label>
<InputText id="plan-name" v-model="localPlan.name" placeholder="Plan Name" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="plan-price" class="text-sm font-medium text-gray-700">Price ($)</label>
<InputNumber id="plan-price" v-model="localPlan.price" mode="currency" currency="USD" locale="en-US" :minFractionDigits="2" />
</div>
<div class="flex flex-col gap-2">
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
<InputText id="plan-cycle" v-model="localPlan.cycle" placeholder="e.g. month, year" />
</div>
</div>
<div class="flex flex-col gap-2">
<label for="plan-desc" class="text-sm font-medium text-gray-700">Description</label>
<Textarea id="plan-desc" v-model="localPlan.description" rows="2" class="w-full" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="plan-storage" class="text-sm font-medium text-gray-700">Storage Limit (bytes)</label>
<InputNumber id="plan-storage" v-model="localPlan.storage_limit" />
</div>
<div class="flex flex-col gap-2">
<label for="plan-uploads" class="text-sm font-medium text-gray-700">Upload Limit (per day)</label>
<InputNumber id="plan-uploads" v-model="localPlan.upload_limit" />
</div>
<div class="flex flex-col gap-2">
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
<InputNumber id="plan-duration" v-model="localPlan.duration_limit" />
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<Checkbox v-model="localPlan.is_active" :binary="true" inputId="plan-active" />
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
</div>
</div>
<template #footer>
<Button label="Cancel" text severity="secondary" @click="visibleModel = false" />
<Button label="Save Changes" icon="i-heroicons-check" @click="onSave" :loading="loading" />
</template>
</Dialog>
</template>

View File

@@ -1,57 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import { computed } from 'vue';
const props = defineProps<{
visible: boolean;
currentPlan?: ModelPlan;
cancelling?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'cancel-subscription'): void;
}>();
const visibleModel = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
});
</script>
<template>
<Dialog v-model:visible="visibleModel" modal header="Manage Subscription" :style="{ width: '30rem' }">
<div class="mb-4">
<p class="text-gray-600 mb-4">You are currently subscribed to <span class="font-bold text-gray-900">{{ currentPlan?.name }}</span>.</p>
<div class="bg-gray-50 p-4 rounded-lg space-y-2 border border-gray-200">
<div class="flex justify-between">
<span class="text-sm text-gray-500">Status</span>
<span class="text-sm font-medium text-green-600">Active</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Renewal Date</span>
<span class="text-sm font-medium text-gray-900">Feb 24, 2026</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Amount</span>
<span class="text-sm font-medium text-gray-900">${{ currentPlan?.price || 0 }}/mo</span>
</div>
</div>
</div>
<p class="text-sm text-gray-600 mb-6">
Canceling your subscription will downgrade you to the Free plan at the end of your current billing period.
</p>
<div class="flex justify-end gap-2">
<Button label="Close" text severity="secondary" @click="visibleModel = false" />
<Button
label="Cancel Subscription"
severity="danger"
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
@click="emit('cancel-subscription')"
:disabled="cancelling"
/>
</div>
</Dialog>
</template>

View File

@@ -1,107 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Skeleton from 'primevue/skeleton';
import { formatBytes } from '@/lib/utils'; // Using utils formatBytes
defineProps<{
plans: ModelPlan[];
isLoading: boolean;
currentPlanId?: string;
subscribingPlanId?: string | null;
isAdmin?: boolean;
}>();
const emit = defineEmits<{
(e: 'subscribe', plan: ModelPlan): void;
(e: 'edit', plan: ModelPlan): void;
}>();
const formatDuration = (seconds?: number) => {
if (!seconds) return '0 mins';
return `${Math.floor(seconds / 60)} mins`;
};
const isPopular = (plan: ModelPlan) => {
return plan.name?.toLowerCase().includes('pro') || plan.name?.toLowerCase().includes('premium');
};
const isCurrentComp = (plan: ModelPlan, currentId?: string) => {
return plan.id === currentId;
}
</script>
<template>
<section>
<div class="flex items-center justify-between mb-8">
<h2 class="text-2xl font-bold text-gray-900">Upgrade your workspace</h2>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div v-for="i in 3" :key="i" class="h-full">
<Skeleton height="300px" borderRadius="16px"></Skeleton>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
<div v-for="plan in plans" :key="plan.id" class="relative group h-full">
<div v-if="isPopular(plan) && !isCurrentComp(plan, currentPlanId)" class="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs font-bold px-3 py-1 rounded-full z-10 shadow-md uppercase tracking-wide">
Recommended
</div>
<!-- Admin Edit Button -->
<Button
v-if="isAdmin"
icon="i-heroicons-pencil-square"
class="absolute top-2 right-2 z-20 !p-2 !w-8 !h-8"
severity="secondary"
text
rounded
@click.stop="emit('edit', plan)"
/>
<div :class="[
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
isCurrentComp(plan, currentPlanId) ? 'border-primary ring-1 ring-primary/50 bg-primary-50/10' : 'border-gray-200 hover:border-gray-300 hover:shadow-lg',
isPopular(plan) && !isCurrentComp(plan, currentPlanId) ? 'shadow-md border-primary/20' : ''
]">
<div class="mb-4">
<h3 class="text-xl font-bold text-gray-900">{{ plan.name }}</h3>
<p class="text-gray-500 text-sm min-h-[2.5rem] mt-2">{{ plan.description }}</p>
</div>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900">${{ plan.price }}</span>
<span class="text-gray-500 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-3 mb-8 flex-grow">
<li class="flex items-center gap-3 text-sm text-gray-700">
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
{{ formatBytes(plan.storage_limit || 0) }} Storage
</li>
<li class="flex items-center gap-3 text-sm text-gray-700">
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
{{ formatDuration(plan.duration_limit) }} Max Duration
</li>
<li class="flex items-center gap-3 text-sm text-gray-700">
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
{{ plan.upload_limit }} Uploads / day
</li>
</ul>
<Button
:label="isCurrentComp(plan, currentPlanId) ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade')"
:icon="subscribingPlanId === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
class="w-full"
:severity="isCurrentComp(plan, currentPlanId) ? 'secondary' : 'primary'"
:outlined="isCurrentComp(plan, currentPlanId)"
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
@click="emit('subscribe', plan)"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,93 +0,0 @@
<script setup lang="ts">
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable from 'primevue/datatable';
import Tag from 'primevue/tag';
interface PaymentHistoryItem {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
}
defineProps<{
history: PaymentHistoryItem[];
}>();
const getStatusSeverity = (status: string) => {
switch (status) {
case 'success':
return 'success';
case 'failed':
return 'danger';
case 'pending':
return 'warn';
default:
return 'info';
}
};
import { useToast } from 'primevue/usetoast';
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
const toast = useToast();
const downloadInvoice = (item: PaymentHistoryItem) => {
toast.add({
severity: 'info',
summary: 'Downloading',
detail: `Downloading invoice #${item.invoiceId}...`,
life: 2000
});
// Simulate download delay
setTimeout(() => {
toast.add({
severity: 'success',
summary: 'Downloaded',
detail: `Invoice #${item.invoiceId} downloaded successfully`,
life: 3000
});
}, 1500);
};
</script>
<template>
<section>
<h2 class="text-2xl font-bold mb-6 text-gray-900">Billing History</h2>
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<DataTable :value="history" responsiveLayout="scroll" class="w-full">
<template #empty>
<div class="text-center py-8 text-gray-500">No payment history found.</div>
</template>
<Column field="date" header="Date" class="font-medium"></Column>
<Column field="amount" header="Amount">
<template #body="slotProps">
${{ slotProps.data.amount }}
</template>
</Column>
<Column field="plan" header="Plan"></Column>
<Column field="status" header="Status">
<template #body="slotProps">
<Tag :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)"
class="capitalize px-2 py-0.5 text-xs" :rounded="true" />
</template>
</Column>
<!-- <Column header="" style="width: 3rem">
<template #body="slotProps">
<Button text rounded severity="secondary" size="small" @click="downloadInvoice(slotProps.data)"
v-tooltip="'Download Invoice'">
<template #icon>
<ArrowDownTray class="w-5 h-5" />
</template>
</Button>
</template>
</Column> -->
</DataTable>
</div>
</section>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { formatBytes } from '@/lib/utils';
import ProgressBar from 'primevue/progressbar';
import { computed } from '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));
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-8 flex flex-col justify-center">
<h3 class="text-lg font-bold text-gray-900 mb-6">Usage Statistics</h3>
<div class="mb-6">
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-600 font-medium">Storage</span>
<span class="text-gray-900 font-bold">{{ storagePercentage }}%</span>
</div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 8px" :class="storagePercentage > 90 ? 'p-progressbar-danger' : ''"></ProgressBar>
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-600 font-medium">Monthly Uploads</span>
<span class="text-gray-900 font-bold">{{ uploadsPercentage }}%</span>
</div>
<ProgressBar :value="uploadsPercentage" :showValue="false" style="height: 8px"></ProgressBar>
<p class="text-xs text-gray-500 mt-2">{{ uploadsUsed }} of {{ uploadsLimit }} uploads</p>
</div>
</div>
</template>

View File

@@ -1,106 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import ProfileHero from './components/ProfileHero.vue';
import ProfileInfoCard from './components/ProfileInfoCard.vue';
import ChangePasswordDialog from './components/ChangePasswordDialog.vue';
import AccountStatusCard from './components/AccountStatusCard.vue';
import LinkedAccountsCard from './components/LinkedAccountsCard.vue';
import { useToast } from 'primevue/usetoast';
const auth = useAuthStore();
const toast = useToast();
// Dialog visibility
const showPasswordDialog = ref(false);
// Refs for dialog components
const passwordDialogRef = ref<InstanceType<typeof ChangePasswordDialog>>();
// Computed storage values
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240); // 10GB default
// Handlers
const handleEditSave = async (data: { username: string; email: string }) => {
try {
await auth.updateProfile(data);
toast.add({
severity: 'success',
summary: 'Profile Updated',
detail: 'Your profile has been updated successfully.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Update Failed',
detail: auth.error || 'Failed to update profile.',
life: 5000
});
}
};
const handlePasswordSave = async (data: { currentPassword: string; newPassword: string }) => {
try {
await auth.changePassword(data.currentPassword, data.newPassword);
showPasswordDialog.value = false;
toast.add({
severity: 'success',
summary: 'Password Changed',
detail: 'Your password has been changed successfully.',
life: 3000
});
} catch (e: any) {
passwordDialogRef.value?.setError(e.message || 'Failed to change password');
}
};
</script>
<template>
<div class="profile-page">
<PageHeader
title="Profile Settings"
description="Manage your account information and preferences."
:breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Profile' }
]"
/>
<div class="max-w-5xl mx-auto space-y-8 pb-12">
<!-- Hero Identity Card -->
<ProfileHero
:user="auth.user"
@logout="auth.logout()"
/>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Personal Info -->
<div class="md:col-span-2">
<ProfileInfoCard
:user="auth.user"
@change-password="showPasswordDialog = true"
/>
</div>
<!-- Stats Side -->
<div class="md:col-span-1 space-y-6">
<AccountStatusCard
:storage-used="storageUsed"
:storage-limit="storageLimit"
/>
<LinkedAccountsCard />
</div>
</div>
</div>
<!-- Dialogs -->
<ChangePasswordDialog
ref="passwordDialogRef"
v-model:visible="showPasswordDialog"
@save="handlePasswordSave"
/>
</div>
</template>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar';
import { computed } from 'vue';
const props = defineProps<{
storageUsed: number;
storageLimit: number;
}>();
const storagePercentage = computed(() =>
Math.min(Math.round((props.storageUsed / props.storageLimit) * 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-white border border-gray-200 rounded-2xl p-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">Account Status</h3>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-600">Storage Used</span>
<span class="font-bold text-gray-900">{{ storagePercentage }}%</span>
</div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px"></ProgressBar>
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<div class="bg-green-50 rounded-lg p-4 border border-green-100 flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-green-600 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<div>
<h4 class="font-bold text-green-800 text-sm">Account Active</h4>
<p class="text-green-600 text-xs mt-0.5">Your subscription is in good standing.</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import Message from 'primevue/message';
import { ref, computed, watch } from 'vue';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
save: [data: { currentPassword: string; newPassword: string }];
}>();
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const error = ref('');
watch(() => props.visible, (val) => {
if (val) {
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
error.value = '';
}
});
const isValid = computed(() => {
return currentPassword.value.length >= 1
&& newPassword.value.length >= 6
&& newPassword.value === confirmPassword.value;
});
const passwordMismatch = computed(() => {
return confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value;
});
const passwordTooShort = computed(() => {
return newPassword.value.length > 0 && newPassword.value.length < 6;
});
const handleSave = () => {
if (!isValid.value) return;
loading.value = true;
error.value = '';
emit('save', {
currentPassword: currentPassword.value,
newPassword: newPassword.value
});
};
const handleClose = () => {
emit('update:visible', false);
};
// Expose methods for parent to control loading state
defineExpose({
setLoading: (val: boolean) => { loading.value = val; },
setError: (msg: string) => { error.value = msg; loading.value = false; }
});
</script>
<template>
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal header="Change Password"
:style="{ width: '28rem' }" :closable="true" :draggable="false">
<div class="space-y-6 pt-2">
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
<div class="flex flex-col gap-2">
<label for="current-password" class="text-sm font-medium text-gray-700">Current Password</label>
<InputText id="current-password" v-model="currentPassword" type="password" class="w-full"
placeholder="Enter current password" />
</div>
<div class="flex flex-col gap-2">
<label for="new-password" class="text-sm font-medium text-gray-700">New Password</label>
<InputText id="new-password" v-model="newPassword" type="password" class="w-full"
placeholder="Enter new password (min 6 characters)"
:class="{ 'p-invalid': passwordTooShort }" />
<small v-if="passwordTooShort" class="text-red-500">Password must be at least 6 characters</small>
</div>
<div class="flex flex-col gap-2">
<label for="confirm-password" class="text-sm font-medium text-gray-700">Confirm New Password</label>
<InputText id="confirm-password" v-model="confirmPassword" type="password" class="w-full"
placeholder="Confirm new password"
:class="{ 'p-invalid': passwordMismatch }" />
<small v-if="passwordMismatch" class="text-red-500">Passwords do not match</small>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3 pt-4">
<Button label="Cancel" severity="secondary" @click="handleClose" :disabled="loading" />
<Button label="Change Password" @click="handleSave" :loading="loading" :disabled="!isValid" />
</div>
</template>
</Dialog>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import Tag from 'primevue/tag';
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">Linked Accounts</h3>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-gray-200 transition-colors">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-red-600" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<span class="font-medium text-gray-700">Google</span>
</div>
<Tag value="Connected" severity="success" class="text-xs px-2"></Tag>
</div>
</div>
</div>
</template>

View File

@@ -1,83 +0,0 @@
<script setup lang="ts">
import type { ModelUser } from '@/api/client';
import Avatar from 'primevue/avatar';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { computed } from 'vue';
const props = defineProps<{
user: ModelUser | null;
}>();
const emit = defineEmits<{
logout: [];
changePassword: [];
}>();
const joinDate = computed(() => {
return new Date(props.user?.created_at || Date.now()).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
</script>
<template>
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900 text-white p-8 md:p-10">
<!-- Background decorations -->
<div class="absolute top-0 right-0 -mt-20 -mr-20 w-80 h-80 bg-primary-500 rounded-full mix-blend-overlay filter blur-3xl"></div>
<div class="absolute bottom-0 left-0 -mb-20 -ml-20 w-80 h-80 bg-purple-500 rounded-full mix-blend-overlay filter blur-3xl"></div>
<div class="relative z-10 flex flex-col md:flex-row items-center gap-8">
<div class="relative">
<div class="absolute inset-0 bg-primary-500 rounded-full blur-lg opacity-40"></div>
<!-- :label="user?.username?.charAt(0).toUpperCase() || 'U'" -->
<Avatar
class="relative border-4 border-gray-800 text-3xl font-bold bg-gradient-to-br from-primary-400 to-primary-600 text-white shadow-2xl"
size="xlarge"
shape="circle"
style="width: 120px; height: 120px; font-size: 3rem;"
image="https://picsum.photos/seed/user123/120/120.jpg"
/>
</div>
<div class="text-center md:text-left space-y-2 flex-grow">
<div class="flex flex-col md:flex-row items-center gap-3 justify-center md:justify-start">
<h2 class="text-3xl font-bold text-white">{{ user?.username || 'User' }}</h2>
<Tag :value="user?.role || 'User'" severity="info" class="uppercase tracking-wider px-2 header-tag" rounded></Tag>
</div>
<p class="text-gray-400 text-lg">{{ user?.email }}</p>
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/>
<line x1="16" x2="16" y1="2" y2="6"/>
<line x1="8" x2="8" y1="2" y2="6"/>
<line x1="3" x2="21" y1="10" y2="10"/>
</svg>
Member since {{ joinDate }}
</p>
</div>
<div class="flex gap-3">
<Button label="Logout" severity="danger" class="border-white/10 text-white hover:bg-white/10 bg-white/5" @click="emit('logout')">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" x2="9" y1="12" y2="12"/>
</svg>
</template>
</Button>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.header-tag) {
background: rgba(255,255,255,0.2) !important;
color: white !important;
border: 1px solid rgba(255,255,255,0.1);
}
</style>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import type { ModelUser } from '@/api/client';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
defineProps<{
user: ModelUser | null;
}>();
const emit = defineEmits<{
edit: [];
changePassword: [];
}>();
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
<div class="flex gap-2">
<Button label="Change Password" text severity="secondary" @click="emit('changePassword')">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</template>
</Button>
</div>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="flex flex-col gap-2">
<label for="username" class="text-sm font-medium text-gray-700">Username</label>
<div class="relative">
<IconField>
<InputIcon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</InputIcon>
<InputText id="username" :value="user?.username" class="w-full pl-10" readonly />
</IconField>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="email" class="text-sm font-medium text-gray-700">Email Address</label>
<div class="relative">
<IconField>
<InputIcon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</InputIcon>
<InputText id="email" :value="user?.email" class="w-full pl-10" readonly />
</IconField>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<!-- <div class="flex flex-col gap-2">
<label for="role" class="text-sm font-medium text-gray-700">Role</label>
<InputText id="role" :value="user?.role || 'User'" class="w-full capitalize bg-gray-50" readonly />
</div> -->
<!-- <div class="flex flex-col gap-2">
<label for="id" class="text-sm font-medium text-gray-700">User ID</label>
<InputText id="id" :value="user?.id || 'N/A'" class="w-full font-mono text-sm bg-gray-50" readonly />
</div> -->
</div>
</div>
</div>
</template>
<style scoped>
:deep(.p-inputtext[readonly]) {
background-color: #f9fafb;
border-color: #e5e7eb;
color: #374151;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<section>
<PageHeader
:title="content[route.name as keyof typeof content]?.title || 'Settings'"
:description="content[route.name as keyof typeof content]?.subtitle || 'Manage your account settings and preferences.'"
:breadcrumbs="breadcrumbs"
/>
<div class="max-w-7xl mx-auto pb-12">
<div class="flex flex-col md:flex-row gap-8 mt-6">
<!-- Sidebar Navigation (GitHub-style) -->
<aside class="md:w-56 shrink-0">
<div class="flex items-center gap-4 mb-8">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<nav class="space-y-6">
<div v-for="section in menuSections" :key="section.title">
<h3 v-if="section.title" class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
{{ section.title }}
</h3>
<ul class="space-y-0.5">
<li v-for="item in section.items" :key="item.value">
<router-link
:to="tabPaths[item.value]"
:class="[
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
currentTab === item.value
? 'bg-primary/10 text-primary font-semibold'
: item.danger
? 'text-danger hover:bg-danger/10'
: 'text-foreground/70 hover:bg-muted hover:text-foreground'
]"
>
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" />
{{ item.label }}
</router-link>
</li>
</ul>
</div>
</nav>
</aside>
<!-- Main Content Area -->
<main class="flex-1 min-w-0">
<router-view />
</main>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import UserIcon from '@/components/icons/UserIcon.vue';
import GlobeIcon from '@/components/icons/Globe.vue';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import AlertTriangle from '@/components/icons/AlertTriangle.vue';
import { useAuthStore } from '@/stores/auth';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import Bell from '@/components/icons/Bell.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue';
import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue';
import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';
const route = useRoute();
const auth = useAuthStore();
// Map tab values to their paths
const tabPaths: Record<string, string> = {
profile: '/settings',
security: '/settings/security',
notifications: '/settings/notifications',
player: '/settings/player',
billing: '/settings/billing',
domains: '/settings/domains',
ads: '/settings/ads',
danger: '/settings/danger',
};
// Menu items grouped by category (GitHub-style)
const menuSections: { title?: string; items: { value: string; label: string; icon: any; danger?: boolean }[] }[] = [
{
title: 'Security',
items: [
{ value: 'security', label: 'Security', icon: UserIcon },
{ value: 'billing', label: 'Billing & Plans', icon: CreditCardIcon },
],
},
{
title: 'Preferences',
items: [
{ value: 'notifications', label: 'Notifications', icon: Bell },
{ value: 'player', label: 'Player', icon: VideoPlayIcon },
],
},
{
title: 'Integrations',
items: [
{ value: 'domains', label: 'Allowed Domains', icon: GlobeIcon },
{ value: 'ads', label: 'Ads & VAST', icon: AdvertisementIcon },
],
},
{
title: 'Danger Zone',
items: [
{ value: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
],
},
] as const;
type TabValue = typeof menuSections[number]['items'][number]['value'];
// Get current tab from route path
const currentTab = computed<TabValue>(() => {
const path = route.path as string;
const tabName = path.replace('/settings', '') || '/profile';
if (tabName === '' || tabName === '/') return 'profile';
return (tabName.replace('/', '') as TabValue) || 'profile';
});
// Breadcrumbs with dynamic tab
const allMenuItems = menuSections.flatMap(section => section.items);
const currentItem = allMenuItems.find(item => item.value === currentTab.value);
const breadcrumbs = [
{ label: 'Dashboard', to: '/overview' },
{ label: 'Settings', to: '/settings' },
...(currentItem ? [{ label: currentItem.label }] : []),
];
const content = {
security: {
title: 'Security & Connected Apps',
subtitle: 'Manage your security settings and connected applications.'
},
notifications: {
title: 'Notifications',
subtitle: 'Choose how you want to receive notifications and updates.'
},
player: {
title: 'Player Settings',
subtitle: 'Configure default video player behavior and features.'
},
billing: {
title: 'Billing & Plans',
subtitle: 'Your current subscription and billing information.'
},
domains: {
title: 'Allowed Domains',
subtitle: 'Add domains to your whitelist to allow embedding content via iframe.'
},
ads: {
title: 'Ads & VAST',
subtitle: 'Create and manage VAST ad templates for your videos.'
},
danger: {
title: 'Danger Zone',
subtitle: 'Irreversible and destructive actions. Be careful!'
}
}
</script>

View File

@@ -0,0 +1,95 @@
<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

@@ -0,0 +1,196 @@
<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 LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
const toast = useToast();
const props = defineProps<{
dialogVisible: boolean;
error: string;
loading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
emailConnected: boolean;
telegramConnected: boolean;
telegramUsername: string;
}>();
const emit = defineEmits<{
(e: 'update:dialogVisible', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'close'): void;
(e: 'change-password'): void;
(e: 'connect-telegram'): void;
(e: 'disconnect-telegram'): void;
}>();
const handleChangePassword = () => {
emit('change-password');
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h3 class="text-sm font-semibold text-foreground mb-3">Connected Accounts</h3>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Email Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full 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 p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full 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="$emit('disconnect-telegram')"
class="press-animated"
/>
<Button
v-else
label="Connect"
size="small"
@click="$emit('connect-telegram')"
class="press-animated"
/>
</div>
</div>
<!-- Change Password Dialog -->
<Dialog
:visible="dialogVisible"
@update:visible="$emit('update:dialogVisible', $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="error" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ error }}
</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"
:model-value="currentPassword"
type="password"
placeholder="Enter current password"
class="w-full"
@update:model-value="$emit('update:currentPassword', $event)"
/>
</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"
:model-value="newPassword"
type="password"
placeholder="Enter new password"
class="w-full"
@update:model-value="$emit('update:newPassword', $event)"
/>
</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"
:model-value="confirmPassword"
type="password"
placeholder="Confirm new password"
class="w-full"
@update:model-value="$emit('update:confirmPassword', $event)"
/>
</IconField>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<Button
label="Cancel"
text
severity="secondary"
@click="$emit('close')"
:disabled="loading"
class="press-animated"
/>
<Button
label="Change Password"
@click="handleChangePassword"
:loading="loading"
class="press-animated"
/>
</div>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<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

@@ -0,0 +1,97 @@
<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

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, 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 UserIcon from '@/components/icons/UserIcon.vue';
const auth = useAuthStore();
const toast = useToast();
const props = defineProps<{
editing: boolean;
username: string;
email: string;
saving: boolean;
}>();
const emit = defineEmits<{
(e: 'update:username', value: string): void;
(e: 'update:email', value: string): void;
(e: 'start-edit'): void;
(e: 'cancel-edit'): void;
(e: 'save'): void;
(e: 'change-password'): void;
}>();
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.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];
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Profile Information</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your personal information and account details.
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-6">
<!-- User Avatar & Name -->
<div class="flex items-center gap-4 pb-4 border-b border-border">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<!-- Form Fields -->
<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
id="username"
:model-value="username"
:readonly="!editing"
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
@update:model-value="emit('update:username', String($event))"
/>
</IconField>
</div>
<div 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
id="email"
:model-value="email"
:readonly="!editing"
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
@update:model-value="emit('update:email', $event|| '')"
/>
</IconField>
</div>
</div>
<!-- Storage Usage -->
<div class="pt-4 border-t border-border">
<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">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" x2="12" y1="3" y2="15"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">Storage Usage</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px" />
</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"
/>
</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"
/>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,198 @@
<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 LockIcon from '@/components/icons/LockIcon.vue';
const toast = useToast();
const props = defineProps<{
twoFactorEnabled: boolean;
changePasswordError: string;
changePasswordLoading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}>();
const emit = defineEmits<{
(e: 'update:twoFactorEnabled', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'toggle-2fa'): void;
(e: 'change-password'): void;
(e: 'close-password-dialog'): void;
(e: 'close-2fa-dialog'): void;
(e: 'confirm-2fa'): void;
}>();
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const handleToggle2FA = async () => {
if (!props.twoFactorEnabled) {
twoFactorDialogVisible.value = true;
} else {
emit('toggle-2fa');
}
};
const confirmTwoFactor = async () => {
emit('confirm-2fa');
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
})
}
];
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your security settings and connected services.
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Account Status -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center shrink-0">
<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">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Account Status</p>
<p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
</div>
<!-- Two-Factor Authentication -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full 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
:model-value="twoFactorEnabled"
@update:model-value="emit('update:twoFactorEnabled', $event)"
@change="handleToggle2FA"
/>
</div>
<!-- Change Password -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full 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="$emit('change-password')"
size="small"
>
Change Password
</Button>
</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>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<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

@@ -0,0 +1,189 @@
<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

@@ -0,0 +1,351 @@
<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 InputNumber from 'primevue/inputnumber';
import Dialog from 'primevue/dialog';
const toast = useToast();
const confirm = useConfirm();
// VAST Templates
interface VastTemplate {
id: string;
name: string;
vastUrl: string;
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
duration?: number;
enabled: boolean;
createdAt: string;
}
const templates = ref<VastTemplate[]>([
{
id: '1',
name: 'Main Pre-roll Ad',
vastUrl: 'https://ads.example.com/vast/pre-roll.xml',
adFormat: 'pre-roll',
enabled: true,
createdAt: '2024-01-10',
},
{
id: '2',
name: 'Mid-roll Ad Break',
vastUrl: 'https://ads.example.com/vast/mid-roll.xml',
adFormat: 'mid-roll',
duration: 30,
enabled: false,
createdAt: '2024-02-15',
},
]);
const showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null);
const formData = ref({
name: '',
vastUrl: '',
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
duration: undefined as number | undefined,
});
const resetForm = () => {
formData.value = {
name: '',
vastUrl: '',
adFormat: 'pre-roll',
duration: undefined,
};
editingTemplate.value = null;
};
const openAddDialog = () => {
resetForm();
showAddDialog.value = true;
};
const openEditDialog = (template: VastTemplate) => {
formData.value = {
name: template.name,
vastUrl: template.vastUrl,
adFormat: template.adFormat,
duration: template.duration,
};
editingTemplate.value = template;
showAddDialog.value = true;
};
const handleSave = () => {
if (!formData.value.name.trim()) {
toast.add({ severity: 'error', summary: 'Name Required', detail: 'Please enter a template name.', life: 3000 });
return;
}
if (!formData.value.vastUrl.trim()) {
toast.add({ severity: 'error', summary: 'VAST URL Required', detail: 'Please enter the VAST tag URL.', life: 3000 });
return;
}
try {
new URL(formData.value.vastUrl);
} catch {
toast.add({ severity: 'error', summary: 'Invalid URL', detail: 'Please enter a valid URL.', life: 3000 });
return;
}
if (formData.value.adFormat === 'mid-roll' && !formData.value.duration) {
toast.add({ severity: 'error', summary: 'Duration Required', detail: 'Mid-roll ads require a duration/interval.', life: 3000 });
return;
}
if (editingTemplate.value) {
const index = templates.value.findIndex(t => t.id === editingTemplate.value!.id);
if (index !== -1) {
templates.value[index] = { ...templates.value[index], ...formData.value };
}
toast.add({ severity: 'success', summary: 'Template Updated', detail: 'VAST template has been updated.', life: 3000 });
} else {
templates.value.push({
id: Math.random().toString(36).substring(2, 9),
...formData.value,
enabled: true,
createdAt: new Date().toISOString().split('T')[0],
});
toast.add({ severity: 'success', summary: 'Template Created', detail: 'VAST template has been created.', life: 3000 });
}
showAddDialog.value = false;
resetForm();
};
const handleToggle = (template: VastTemplate) => {
template.enabled = !template.enabled;
toast.add({
severity: 'info',
summary: template.enabled ? 'Template Enabled' : 'Template Disabled',
detail: `${template.name} has been ${template.enabled ? 'enabled' : 'disabled'}.`,
life: 2000
});
};
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);
toast.add({ severity: 'info', summary: 'Template Deleted', detail: 'VAST template has been removed.', life: 3000 });
}
});
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.add({ severity: 'success', summary: 'Copied', detail: 'URL copied to clipboard.', life: 2000 });
};
const getAdFormatLabel = (format: string) => {
const labels: Record<string, string> = {
'pre-roll': 'Pre-roll',
'mid-roll': 'Mid-roll',
'post-roll': 'Post-roll',
};
return labels[format] || format;
};
const getAdFormatColor = (format: string) => {
const colors: Record<string, string> = {
'pre-roll': 'bg-blue-500/10 text-blue-500',
'mid-roll': 'bg-yellow-500/10 text-yellow-500',
'post-roll': 'bg-purple-500/10 text-purple-500',
};
return colors[format] || 'bg-gray-500/10 text-gray-500';
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Ads & VAST</h2>
<p class="text-sm text-foreground/60 mt-0.5">
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"
/>
</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>
<div class="text-xs text-foreground/70">
VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.
</div>
</div>
</div>
<!-- Templates Table -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Template</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Format</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">VAST URL</th>
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Status</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="template in templates"
:key="template.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div>
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
<p class="text-xs text-foreground/50 mt-0.5">Created {{ template.createdAt }}</p>
</div>
</td>
<td class="px-6 py-3">
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
{{ getAdFormatLabel(template.adFormat) }}
</span>
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
({{ template.duration }}s)
</span>
</td>
<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)"
/>
</div>
</td>
<td class="px-6 py-3 text-center">
<ToggleSwitch
: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)"
/>
</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>
<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>
</tr>
</tbody>
</table>
</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"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">Template Name</label>
<InputText
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
id="vastUrl"
v-model="formData.vastUrl"
placeholder="https://ads.example.com/vast/tag.xml"
class="w-full"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">Ad Format</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="format in ['pre-roll', 'mid-roll', 'post-roll']"
:key="format"
@click="formData.adFormat = format as any"
:class="[
'px-3 py-2 border rounded-md text-sm font-medium capitalize transition-all',
formData.adFormat === format
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50'
]"
>
{{ format }}
</button>
</div>
</div>
<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
id="duration"
v-model="formData.duration"
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"
/>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,159 @@
<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 WalletBalanceCard from '../components/WalletBalanceCard.vue';
import CurrentPlanCard from '../components/CurrentPlanCard.vue';
import UsageStatsCard from '../components/UsageStatsCard.vue';
import AvailablePlansCard from '../components/AvailablePlansCard.vue';
import PaymentHistoryCard from '../components/PaymentHistoryCard.vue';
const toast = useToast();
const auth = useAuthStore();
const { data, isPending, isLoading, refresh } = useQuery({
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
});
const subscribing = ref<string | null>(null);
// Mock Payment History Data
const paymentHistory = ref([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]);
// Computed Usage (Mock if not in store)
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
// Wallet balance (from user data or mock)
const walletBalance = computed(() => 0);
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
});
const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return;
subscribing.value = plan.id;
try {
await client.payments.paymentsCreate({
amount: plan.price || 0,
plan_id: plan.id
});
toast.add({
severity: 'success',
summary: 'Subscription Successful',
detail: `Successfully subscribed to ${plan.name}`,
life: 3000
});
paymentHistory.value.unshift({
id: `inv_${Date.now()}`,
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
amount: plan.price || 0,
plan: plan.name || 'Unknown',
status: 'success',
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`
});
} catch (err: any) {
console.error(err);
toast.add({
severity: 'error',
summary: 'Subscription Failed',
detail: err.message || 'Failed to subscribe',
life: 5000
});
} finally {
subscribing.value = null;
}
};
const handleTopup = async (amount: number) => {
try {
// Simulate API call for top-up
await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({
severity: 'success',
summary: 'Top-up Successful',
detail: `$${amount} has been added to your wallet.`,
life: 3000
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Top-up Failed',
detail: e.message || 'Failed to process top-up.',
life: 5000
});
}
};
const handleDownloadInvoice = (item: typeof paymentHistory.value[number]) => {
toast.add({
severity: 'info',
summary: 'Downloading',
detail: `Downloading invoice #${item.invoiceId}...`,
life: 2000
});
setTimeout(() => {
toast.add({
severity: 'success',
summary: 'Downloaded',
detail: `Invoice #${item.invoiceId} downloaded successfully`,
life: 3000
});
}, 1500);
};
</script>
<template>
<div class="space-y-6">
<WalletBalanceCard
:balance="walletBalance"
@topup="handleTopup"
/>
<CurrentPlanCard
:current-plan="currentPlan"
@manage="() => {}"
/>
<UsageStatsCard
:storage-used="storageUsed"
:storage-limit="storageLimit"
:uploads-used="uploadsUsed"
:uploads-limit="uploadsLimit"
/>
<AvailablePlansCard
:plans="data?.data?.data.plans || []"
:is-loading="isLoading"
:current-plan-id="currentPlanId"
:subscribing-plan-id="subscribing"
@subscribe="subscribe"
/>
<PaymentHistoryCard
:history="paymentHistory"
@download="handleDownloadInvoice"
/>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button';
const toast = useToast();
const confirm = useConfirm();
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',
summary: 'Account deletion requested',
detail: 'Your account deletion request has been submitted.',
life: 5000
});
}
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-danger">Danger Zone</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Irreversible and destructive actions. Be careful!
</p>
</div>
<!-- Danger Zone Content -->
<div class="p-6">
<div class="border-2 border-danger/30 rounded-md bg-danger/5">
<!-- Delete Account -->
<div class="flex items-start justify-between px-5 py-4 border-b border-danger/20">
<div>
<h3 class="text-sm font-semibold text-foreground">Delete Account</h3>
<p class="text-xs text-foreground/60 mt-1">
Permanently delete your account and all associated data.
</p>
</div>
<Button
label="Delete Account"
icon="pi pi-trash"
severity="danger"
size="small"
@click="handleDeleteAccount"
class="press-animated"
/>
</div>
<!-- Clear All Data -->
<div class="flex items-start justify-between px-5 py-4">
<div>
<h3 class="text-sm font-semibold text-foreground">Clear All Data</h3>
<p class="text-xs text-foreground/60 mt-1">
Remove all your videos, playlists, and activity history.
</p>
</div>
<Button
label="Clear Data"
icon="pi pi-eraser"
severity="danger"
size="small"
outlined
class="press-animated"
/>
</div>
</div>
<!-- Warning Banner -->
<div class="mt-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>
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Warning</p>
<p>
These actions are permanent and cannot be undone.
Make sure you have backed up any important data before proceeding.
</p>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,237 @@
<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';
const toast = useToast();
const confirm = useConfirm();
// Domain whitelist for iframe embedding
const domains = ref([
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
]);
const newDomain = ref('');
const showAddDialog = ref(false);
const handleAddDomain = () => {
if (!newDomain.value.trim()) {
toast.add({
severity: 'error',
summary: 'Invalid Domain',
detail: 'Please enter a valid domain name.',
life: 3000
});
return;
}
// Check for duplicates
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
if (exists) {
toast.add({
severity: 'error',
summary: 'Domain Already Added',
detail: 'This domain is already in your whitelist.',
life: 3000
});
return;
}
domains.value.push({
id: Math.random().toString(36).substring(2, 9),
name: newDomain.value.trim().toLowerCase(),
addedAt: new Date().toISOString().split('T')[0]
});
newDomain.value = '';
showAddDialog.value = false;
toast.add({
severity: 'success',
summary: 'Domain Added',
detail: `${newDomain.value} has been added to your whitelist.`,
life: 3000
});
};
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) {
domains.value.splice(index, 1);
}
toast.add({
severity: 'info',
summary: 'Domain Removed',
detail: `${domain.name} has been removed from your whitelist.`,
life: 3000
});
}
});
};
const getIframeCode = () => {
return `<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>`;
};
const copyIframeCode = () => {
navigator.clipboard.writeText(getIframeCode());
toast.add({
severity: 'success',
summary: 'Copied',
detail: 'Embed code copied to clipboard.',
life: 2000
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Allowed Domains</h2>
<p class="text-sm text-foreground/60 mt-0.5">
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"
/>
</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>
<div class="text-xs text-foreground/70">
Only domains in your whitelist can embed your content using iframe.
</div>
</div>
</div>
<!-- Domain List -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Domain</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Added Date</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="domain in domains"
:key="domain.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<i class="pi pi-globe text-foreground/40 text-sm"></i>
<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)"
/>
</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>
<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>
</tr>
</tbody>
</table>
</div>
<!-- Embed Code Section -->
<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"
/>
</div>
<p class="text-xs text-foreground/60 mb-2">
Use this iframe code to embed content on your whitelisted domains.
</p>
<pre class="bg-surface border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ getIframeCode() }}</code></pre>
</div>
<!-- Add Domain Dialog -->
<Dialog
v-model:visible="showAddDialog"
header="Add Domain to Whitelist"
:modal="true"
:closable="true"
class="w-full 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
id="domain"
v-model="newDomain"
placeholder="example.com"
class="w-full"
@keyup.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>
<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>
</div>
</div>
</div>
</div>
<template #footer>
<Button
label="Cancel"
text
@click="showAddDialog = false"
/>
<Button
label="Add Domain"
icon="pi pi-check"
@click="handleAddDomain"
class="press-animated"
/>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue';
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 notificationSettings = ref({
email: true,
push: true,
marketing: false,
telegram: false,
});
const notificationTypes = [
{
key: 'email' as const,
title: 'Email Notifications',
description: 'Receive updates and alerts via email',
icon: MailIcon,
bgColor: 'bg-primary/10',
iconColor: 'text-primary',
},
{
key: 'push' as const,
title: 'Push Notifications',
description: 'Get instant alerts in your browser',
icon: BellIcon,
bgColor: 'bg-accent/10',
iconColor: 'text-accent',
},
{
key: 'marketing' as const,
title: 'Marketing Emails',
description: 'Receive promotions and product updates',
icon: SendIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
{
key: 'telegram' as const,
title: 'Telegram Notifications',
description: 'Receive updates via Telegram',
icon: TelegramIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
];
defineEmits<{
save: [];
}>();
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Notifications</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Choose how you want to receive notifications and updates.
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<div
v-for="type in notificationTypes"
:key="type.key"
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 flex items-center justify-center shrink-0 ${type.bgColor}`"
>
<component :is="type.icon" :class="`${type.iconColor} w-5 h-5`" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ type.title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p>
</div>
</div>
<ToggleSwitch v-model="notificationSettings[type.key]" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref } from 'vue';
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';
import WifiIcon from '@/components/icons/WifiIcon.vue';
import MonitorIcon from '@/components/icons/MonitorIcon.vue';
const playerSettings = ref({
autoplay: true,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
Chromecast: false,
});
defineEmits<{
save: [];
}>();
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Player Settings</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Configure default video player behavior and features.
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<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-primary/10 flex items-center justify-center shrink-0"
>
<PlayIcon class="text-primary w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Autoplay</p>
<p class="text-xs text-foreground/60 mt-0.5">
Automatically start videos when loaded
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.autoplay" />
</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>
</template>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
import ProfileInformationCard from '../components/ProfileInformationCard.vue';
import SecuritySettingsCard from '../components/SecuritySettingsCard.vue';
import ConnectedAccountsCard from '../components/ConnectedAccountsCard.vue';
const auth = useAuthStore();
const toast = useToast();
// Form state
const editing = ref(false);
const username = ref('');
const email = ref('');
const saving = ref(false);
// 2FA state
const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false);
// Connected accounts state
const emailConnected = ref(true);
const telegramConnected = ref(false);
const telegramUsername = ref('');
// Change password state
const changePasswordDialogVisible = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const changePasswordLoading = ref(false);
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
const openChangePassword = () => {
changePasswordDialogVisible.value = true;
changePasswordError.value = '';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
};
const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = 'Passwords do not match';
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = 'Password must be at least 6 characters';
return;
}
changePasswordLoading.value = true;
try {
await auth.changePassword(currentPassword.value, newPassword.value);
changePasswordDialogVisible.value = false;
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: 'Password Changed',
detail: 'Your password has been changed successfully.',
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || 'Failed to change password';
} finally {
changePasswordLoading.value = false;
}
};
// Toggle 2FA
const toggleTwoFactor = async () => {
if (!twoFactorEnabled.value) {
// Enable 2FA - generate secret and QR code
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true;
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Failed to enable two-factor authentication.',
life: 5000
});
twoFactorEnabled.value = false;
}
} else {
// Disable 2FA
try {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: '2FA Disabled',
detail: 'Two-factor authentication has been disabled.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disable 2FA Failed',
detail: 'Failed to disable two-factor authentication.',
life: 5000
});
twoFactorEnabled.value = true;
}
}
};
// Confirm 2FA setup
const confirmTwoFactor = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: '2FA Enabled',
detail: 'Two-factor authentication has been enabled successfully.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Invalid verification code. Please try again.',
life: 5000
});
}
};
// Connect Telegram
const connectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
telegramConnected.value = true;
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: 'Telegram Connected',
detail: `Connected to ${telegramUsername.value}`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Connection Failed',
detail: 'Failed to connect Telegram account.',
life: 5000
});
}
};
// Disconnect Telegram
const disconnectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
telegramConnected.value = false;
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: 'Telegram Disconnected',
detail: 'Your Telegram account has been disconnected.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disconnect Failed',
detail: 'Failed to disconnect Telegram account.',
life: 5000
});
}
};
</script>
<template>
<div class="space-y-6">
<SecuritySettingsCard
v-model:two-factor-enabled="twoFactorEnabled"
:change-password-error="changePasswordError"
:change-password-loading="changePasswordLoading"
:current-password="currentPassword"
:new-password="newPassword"
:confirm-password="confirmPassword"
@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
:dialog-visible="changePasswordDialogVisible"
@update:dialog-visible="changePasswordDialogVisible = $event"
:error="changePasswordError"
:loading="changePasswordLoading"
:current-password="currentPassword"
:new-password="newPassword"
:confirm-password="confirmPassword"
:email-connected="emailConnected"
:telegram-connected="telegramConnected"
:telegram-username="telegramUsername"
@close="changePasswordDialogVisible = false"
@change-password="changePassword"
@connect-telegram="connectTelegram"
@disconnect-telegram="disconnectTelegram"
@update:current-password="currentPassword = $event"
@update:new-password="newPassword = $event"
@update:confirm-password="confirmPassword = $event"
/>
</div>
</template>