update ui

This commit is contained in:
2026-01-23 02:21:55 +07:00
parent 1fe77f24dc
commit 55f467a10e
11 changed files with 582 additions and 81 deletions

View File

@@ -681,6 +681,6 @@ export class Api<
export const client = new Api({ export const client = new Api({
baseUrl: 'r', baseUrl: 'r',
// baseUrl: 'https://cheapest-representations-corporations-related.trycloudflare.com', // baseUrl: 'https://api.pipic.fun',
customFetch customFetch
}); });

View File

@@ -10,7 +10,7 @@ export const customFetch = async (url: string, options: RequestInit) => {
Object.assign(options, { Object.assign(options, {
headers: c.req.header() headers: c.req.header()
}); });
const res = await fetch(["https://cheapest-representations-corporations-related.trycloudflare.com", url.replace(/r\//, '')].join('/'), options); const res = await fetch(["https://api.pipic.fun", url.replace(/r\//, '')].join('/'), options);
res.headers.forEach((value, key) => { res.headers.forEach((value, key) => {
c.header(key, value); c.header(key, value);
}); });

View File

@@ -19,7 +19,7 @@ const links = [
{ href: "/", label: "Overview", icon: Home, type: "a", className }, { href: "/", label: "Overview", icon: Home, type: "a", className },
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className }, { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
{ href: "/video", label: "Video", icon: Video, type: "a", className }, { href: "/video", label: "Video", icon: Video, type: "a", className },
{ href: "/plans", label: "Plans", icon: Credit, type: "a", className }, { href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
{ href: "/notification", label: "Notification", icon: Bell, type: "a", className }, { href: "/notification", label: "Notification", icon: Bell, type: "a", className },
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' }, { href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' },
]; ];

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { VNode } from 'vue'; import { VNode } from 'vue';
import VueHead from '@/components/VueHead';
interface Breadcrumb { interface Breadcrumb {
label: string; label: string;
@@ -67,6 +68,7 @@ const getButtonClass = (variant?: string) => {
<div class="flex items-start justify-between gap-4 flex-wrap"> <div class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold text-gray-900 mb-1">{{ title }}</h1> <h1 class="text-3xl font-bold text-gray-900 mb-1">{{ title }}</h1>
<vue-head :input="{ title, meta: [{ name: 'description', content: description || '' }] }" />
<p v-if="description" class="text-gray-600">{{ description }}</p> <p v-if="description" class="text-gray-600">{{ description }}</p>
</div> </div>

View File

@@ -27,18 +27,19 @@ app.use(cors(), async (c, next) => {
const path = c.req.path const path = c.req.path
if (path !== '/r' && !path.startsWith('/r/')) { if (path !== '/r' && !path.startsWith('/r/')) {
return next() return await next()
} }
const url = new URL(c.req.url) const url = new URL(c.req.url)
url.host = 'cheapest-representations-corporations-related.trycloudflare.com' url.host = 'api.pipic.fun'
url.protocol = 'https:' url.protocol = 'https:'
url.pathname = path.replace(/^\/r/, '') || '/' url.pathname = path.replace(/^\/r/, '') || '/'
url.port = '' url.port = ''
const req = new Request(url.toString(), c.req.raw); const req = new Request(url.toString(), c.req.raw);
const res = await fetch(req).catch(err => console.error('Error during proxy request: ', err.message)); return fetch(req);
// const res = await fetch(req).catch(err => console.error('Error during proxy request: ', err.message));
// return c.body(res, res.status, res.headers); // return c.body(res, res.status, res.headers);
console.log('Proxy request to: ', url.toString(), ' response: ', res?.status, JSON.stringify(c.req.header(), null, 2)); // console.log('Proxy request to: ', url.toString(), ' response: ', res?.status, JSON.stringify(c.req.header(), null, 2));
return res // return res
}); });
app.get("/.well-known/*", (c) => { app.get("/.well-known/*", (c) => {
return c.json({ ok: true }); return c.json({ ok: true });

View File

@@ -48,3 +48,5 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
img.src = url; img.src = url;
}); });
} }

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import StatsCard from '@/components/dashboard/StatsCard.vue'; import StatsCard from '@/components/dashboard/StatsCard.vue';
import { client, type ModelVideo } from '@/api/client'; import { client, type ModelVideo } from '@/api/client';
import Skeleton from 'primevue/skeleton';
const router = useRouter(); const router = useRouter();
const loading = ref(true); const loading = ref(true);
@@ -149,8 +150,51 @@ onMounted(() => {
/> />
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-20"> <div v-if="loading" class="animate-pulse">
<div class="i-svg-spinners-180-ring-with-bg text-4xl text-primary"></div> <!-- Stats Grid Skeleton -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<div class="space-y-2">
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
<Skeleton width="8rem" height="2rem"></Skeleton>
</div>
<Skeleton shape="circle" size="3rem"></Skeleton>
</div>
<Skeleton width="4rem" height="1rem"></Skeleton>
</div>
</div>
<!-- Quick Actions Skeleton -->
<div class="mb-8">
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
<Skeleton width="100%" height="1rem"></Skeleton>
</div>
</div>
</div>
<!-- Recent Videos Skeleton -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<Skeleton width="8rem" height="1.5rem"></Skeleton>
<Skeleton width="5rem" height="1rem"></Skeleton>
</div>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
<div class="flex gap-4">
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
<div class="flex-1 space-y-2">
<Skeleton width="30%" height="1rem"></Skeleton>
<Skeleton width="20%" height="0.8rem"></Skeleton>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div v-else> <div v-else>

View File

@@ -97,12 +97,12 @@ const routes: RouteData[] = [
} }
}, },
{ {
path: "plans", path: "payments-and-plans",
name: "plans", name: "payments-and-plans",
component: () => import("./plans/Plans.vue"), component: () => import("./plans/Plans.vue"),
meta: { meta: {
head: { head: {
title: 'Plans & Billing', title: 'Payments & Plans - Holistream',
meta: [ meta: [
{ name: 'description', content: 'Manage your plans and billing information.' }, { name: 'description', content: 'Manage your plans and billing information.' },
], ],
@@ -122,7 +122,7 @@ const routes: RouteData[] = [
{ {
path: "profile", path: "profile",
name: "profile", name: "profile",
component: () => import("./add/Add.vue"), // TODO: create profile page component: () => import("./profile/Profile.vue"), // TODO: create profile page
meta: { meta: {
head: { head: {
title: 'Profile - Holistream', title: 'Profile - Holistream',

View File

@@ -1,54 +1,53 @@
<template>
<div class="p-6">
<h1 class="text-3xl font-bold mb-6">Choose Your Plan</h1>
<div v-if="loading" class="flex justify-center">
<div class="i-svg-spinners-180-ring-with-bg text-4xl"></div>
</div>
<div v-else-if="error" class="text-red-500">
{{ error }}
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div v-for="plan in plans" :key="plan.id" class="border rounded-lg p-6 shadow-md bg-white dark:bg-gray-800 flex flex-col">
<h2 class="text-xl font-semibold mb-2">{{ plan.name }}</h2>
<div class="text-3xl font-bold mb-4">${{ plan.price }}<span class="text-sm font-normal text-gray-500">/{{ plan.cycle }}</span></div>
<p class="text-gray-600 dark:text-gray-300 mb-6 flex-grow">{{ plan.description }}</p>
<ul class="mb-6 space-y-2">
<li class="flex items-center">
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
<span>Storage: {{ formatBytes(plan.storage_limit || 0) }}</span>
</li>
<li class="flex items-center">
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
<span>Max Duration: {{ formatDuration(plan.duration_limit || 0) }}</span>
</li>
<li class="flex items-center">
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
<span>Uploads: {{ plan.upload_limit }} / day</span>
</li>
</ul>
<button
@click="subscribe(plan)"
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white rounded transition-colors disabled:opacity-50"
:disabled="subscribing === plan.id"
>
<span v-if="subscribing === plan.id" class="i-svg-spinners-180-ring-with-bg mr-2"></span>
{{ subscribing === plan.id ? 'Processing...' : 'Subscribe' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { client, type ModelPlan } from '@/api/client'; import { client, type ModelPlan } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import Card from 'primevue/card';
import Button from 'primevue/button';
import Skeleton from 'primevue/skeleton';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import ProgressBar from 'primevue/progressbar';
import Dialog from 'primevue/dialog';
const auth = useAuthStore();
const plans = ref<ModelPlan[]>([]); const plans = ref<ModelPlan[]>([]);
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const subscribing = ref<string | null>(null); 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 storagePercentage = computed(() => Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100));
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
const uploadsPercentage = computed(() => Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100));
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(plans.value) && plans.value.length > 0) return plans.value[0].id; // Fallback to first plan
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(plans.value)) return undefined;
return plans.value.find(p => p.id === currentPlanId.value);
});
const fetchPlans = async () => { const fetchPlans = async () => {
loading.value = true; loading.value = true;
@@ -57,20 +56,11 @@ const fetchPlans = async () => {
const response = await client.plans.plansList(); const response = await client.plans.plansList();
if (response.data && Array.isArray(response.data)) { if (response.data && Array.isArray(response.data)) {
plans.value = response.data; plans.value = response.data;
} else if (response.data && Array.isArray((response.data as any).data)) {
// Handle paginated or wrapped response
plans.value = (response.data as any).data;
} else { } else {
// Fallback or handle unexpected structure? plans.value = [];
// Based on client.ts it returns response.data as ModelPlan[] directly in the custom wrapper?
// Wait, client.ts says: r.data = data. So if the response matches schema, it's inside data.
// Let's re-read client.ts.
// plansList defined as request<ResponseResponse & { data?: ModelPlan[] }>
// So yes, response.data which is the body, and inside that, there is a data property.
// wait, let's check client.ts generated code again.
// plansList returns Promise<HttpResponse<...>>
// HttpResponse has .data property which IS the body.
// The body type is ResponseResponse & { data?: ModelPlan[] }
// So we access response.data.data
plans.value = response.data.data || [];
} }
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
@@ -84,13 +74,22 @@ const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return; if (!plan.id) return;
subscribing.value = plan.id; subscribing.value = plan.id;
try { try {
// Mock payment for now as per plan, or call API if ready
// client.payments.paymentsCreate({ amount: plan.price || 0, plan_id: plan.id });
await client.payments.paymentsCreate({ await client.payments.paymentsCreate({
amount: plan.price || 0, amount: plan.price || 0,
plan_id: plan.id plan_id: plan.id
}); });
// Update local state mock
// In real app, we would re-fetch user profile
alert(`Successfully subscribed to ${plan.name}`); 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) { } catch (err: any) {
console.error(err); console.error(err);
alert('Failed to subscribe: ' + (err.message || 'Unknown error')); alert('Failed to subscribe: ' + (err.message || 'Unknown error'));
@@ -99,6 +98,20 @@ const subscribe = async (plan: ModelPlan) => {
} }
}; };
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;
}
};
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -107,11 +120,236 @@ const formatBytes = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
const formatDuration = (seconds: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return '0 mins';
return `${Math.floor(seconds / 60)} 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) => {
return plan.id === currentPlanId.value;
}
const getStatusSeverity = (status: string) => {
switch (status) {
case 'success':
return 'success';
case 'failed':
return 'danger';
case 'pending':
return 'warn';
default:
return 'info';
}
};
onMounted(() => { onMounted(() => {
fetchPlans(); fetchPlans();
}); });
</script> </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="!loading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Current Plan Card -->
<div class="lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8 shadow-xl">
<!-- Background decorations -->
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full mix-blend-overlay filter 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 mix-blend-overlay filter 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="showManageDialog = true" />
</div>
</div>
</div>
<!-- Usage Stats Card -->
<div class="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm 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>
</div>
<!-- Upgrade Section -->
<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="loading" 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)" 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>
<div :class="[
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
isCurrentComp(plan) ? '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) ? '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) ? 'Current Plan' : (subscribing === plan.id ? 'Processing...' : 'Upgrade')"
:icon="subscribing === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
class="w-full"
:severity="isCurrentComp(plan) ? 'secondary' : 'primary'"
:outlined="isCurrentComp(plan)"
:disabled="!!subscribing || isCurrentComp(plan)"
@click="subscribe(plan)"
/>
</div>
</div>
</div>
</section>
<!-- Payment History Section -->
<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 shadow-sm">
<DataTable :value="paymentHistory" tableStyle="min-width: 50rem"
:pt="{
thead: { class: 'bg-gray-50 border-b border-gray-200' },
headerRow: { class: 'text-gray-500 text-xs font-semibold uppercase tracking-wider' },
bodyRow: { class: 'text-gray-700 hover:bg-gray-50/50' }
}"
>
<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>
<Button icon="i-heroicons-arrow-down-tray" text rounded severity="secondary" size="small" />
</template>
</Column>
</DataTable>
</div>
</section>
<Dialog v-model:visible="showManageDialog" 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="showManageDialog = false" />
<Button
label="Cancel Subscription"
severity="danger"
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
@click="cancelSubscription"
:disabled="cancelling"
/>
</div>
</Dialog>
</div>
</div>
</template>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import Card from 'primevue/card';
import Avatar from 'primevue/avatar';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import ProgressBar from 'primevue/progressbar';
const auth = useAuthStore();
// Computed for display
const joinDate = computed(() => {
return new Date(auth.user?.created_at || Date.now()).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240); // 10GB default
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="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 -->
<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 shadow-xl">
<!-- 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 opacity-20"></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 opacity-20"></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>
<Avatar
:label="auth.user?.username?.charAt(0).toUpperCase() || 'U'"
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;"
/>
</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">{{ auth.user?.username || 'User' }}</h2>
<Tag :value="auth.user?.role || 'User'" severity="info" class="uppercase tracking-wider px-2 header-tag" rounded></Tag>
</div>
<p class="text-gray-400 text-lg">{{ auth.user?.email }}</p>
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
<span class="i-heroicons-calendar"></span>
Member since {{ joinDate }}
</p>
</div>
<div class="flex gap-3">
<Button label="Logout" icon="i-heroicons-arrow-right-on-rectangle" severity="secondary" class="border-white/10 text-white hover:bg-white/10 bg-white/5" @click="auth.logout()" />
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Personal Info -->
<div class="md:col-span-2">
<div class="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
<Button label="Edit Profile" icon="i-heroicons-pencil" text severity="secondary" disabled />
</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">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 i-heroicons-user"></span>
<InputText id="username" :value="auth.user?.username" class="w-full pl-10" readonly />
</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">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 i-heroicons-envelope"></span>
<InputText id="email" :value="auth.user?.email" class="w-full pl-10" readonly />
</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="auth.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="auth.user?.id || 'N/A'" class="w-full font-mono text-sm bg-gray-50" readonly />
</div>
</div>
</div>
</div>
</div>
<!-- Stats Side -->
<div class="md:col-span-1 space-y-6">
<!-- Account Status -->
<div class="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm">
<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">
<span class="i-heroicons-check-circle text-green-600 text-xl mt-0.5"></span>
<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>
<!-- Linked Accounts (Mock) -->
<div class="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm">
<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 text-red-600 font-bold text-xs">G</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>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Custom override for PrimeVue Avatar size if class utils fail */
:deep(.p-avatar-xl) {
width: 6rem;
height: 6rem;
}
:deep(.header-tag) {
background: rgba(255,255,255,0.2) !important;
color: white !important;
border: 1px solid rgba(255,255,255,0.1);
}
:deep(.p-inputtext[readonly]) {
background-color: #f9fafb;
border-color: #e5e7eb;
color: #374151;
}
</style>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, createStaticVNode } from 'vue'; import { ref, onMounted, createStaticVNode, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import EmptyState from '@/components/dashboard/EmptyState.vue'; import EmptyState from '@/components/dashboard/EmptyState.vue';
import { client, type ModelVideo } from '@/api/client'; import { client, type ModelVideo } from '@/api/client';
import Skeleton from 'primevue/skeleton';
const router = useRouter(); const router = useRouter();
const videos = ref<ModelVideo[]>([]); const videos = ref<ModelVideo[]>([]);
@@ -131,6 +133,8 @@ const deleteVideo = async (videoId?: string) => {
} }
}; };
onMounted(() => { onMounted(() => {
fetchVideos(); fetchVideos();
}); });
@@ -157,7 +161,7 @@ onMounted(() => {
/> />
<!-- Filters & Search --> <!-- Filters & Search -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-6"> <div class="bg-white border-b border-gray-200 pb-4 mb-6">
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<!-- Search --> <!-- Search -->
<div class="flex-1"> <div class="flex-1">
@@ -209,8 +213,36 @@ onMounted(() => {
</div> </div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-20"> <div v-if="loading" class="animate-pulse">
<div class="i-svg-spinners-180-ring-with-bg text-4xl text-primary"></div> <!-- Grid Skeleton -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<Skeleton height="150px" width="100%"></Skeleton>
<div class="p-4">
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
<div class="flex justify-between">
<Skeleton width="3rem" height="1rem"></Skeleton>
<Skeleton width="3rem" height="1rem"></Skeleton>
</div>
</div>
</div>
</div>
<!-- Table Skeleton -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
<div class="flex gap-4 items-center">
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
<div class="flex-1">
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
<Skeleton width="30%" height="1rem"></Skeleton>
</div>
<Skeleton width="10%" height="1rem"></Skeleton>
<Skeleton width="10%" height="1rem"></Skeleton>
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
</div>
</div>
</div>
</div> </div>
<!-- Error State --> <!-- Error State -->