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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
168
src/routes/settings/Settings.vue
Normal file
168
src/routes/settings/Settings.vue
Normal 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>
|
||||
95
src/routes/settings/components/AvailablePlansCard.vue
Normal file
95
src/routes/settings/components/AvailablePlansCard.vue
Normal 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>
|
||||
196
src/routes/settings/components/ConnectedAccountsCard.vue
Normal file
196
src/routes/settings/components/ConnectedAccountsCard.vue
Normal 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>
|
||||
39
src/routes/settings/components/CurrentPlanCard.vue
Normal file
39
src/routes/settings/components/CurrentPlanCard.vue
Normal 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>
|
||||
97
src/routes/settings/components/PaymentHistoryCard.vue
Normal file
97
src/routes/settings/components/PaymentHistoryCard.vue
Normal 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>
|
||||
165
src/routes/settings/components/ProfileInformationCard.vue
Normal file
165
src/routes/settings/components/ProfileInformationCard.vue
Normal 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>
|
||||
198
src/routes/settings/components/SecuritySettingsCard.vue
Normal file
198
src/routes/settings/components/SecuritySettingsCard.vue
Normal 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>
|
||||
83
src/routes/settings/components/UsageStatsCard.vue
Normal file
83
src/routes/settings/components/UsageStatsCard.vue
Normal 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>
|
||||
189
src/routes/settings/components/WalletBalanceCard.vue
Normal file
189
src/routes/settings/components/WalletBalanceCard.vue
Normal 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>
|
||||
351
src/routes/settings/pages/AdsVast.vue
Normal file
351
src/routes/settings/pages/AdsVast.vue
Normal 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>
|
||||
159
src/routes/settings/pages/Billing.vue
Normal file
159
src/routes/settings/pages/Billing.vue
Normal 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>
|
||||
94
src/routes/settings/pages/DangerZone.vue
Normal file
94
src/routes/settings/pages/DangerZone.vue
Normal 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>
|
||||
237
src/routes/settings/pages/DomainsDns.vue
Normal file
237
src/routes/settings/pages/DomainsDns.vue
Normal 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>
|
||||
87
src/routes/settings/pages/NotificationSettings.vue
Normal file
87
src/routes/settings/pages/NotificationSettings.vue
Normal 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>
|
||||
172
src/routes/settings/pages/PlayerSettings.vue
Normal file
172
src/routes/settings/pages/PlayerSettings.vue
Normal 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>
|
||||
262
src/routes/settings/pages/SecurityNConnected.vue
Normal file
262
src/routes/settings/pages/SecurityNConnected.vue
Normal 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>
|
||||
Reference in New Issue
Block a user