update ui
This commit is contained in:
6
components.d.ts
vendored
6
components.d.ts
vendored
@@ -18,6 +18,7 @@ declare module 'vue' {
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
Drawer: typeof import('primevue/drawer')['default']
|
||||
@@ -25,6 +26,8 @@ declare module 'vue' {
|
||||
FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
IconField: typeof import('primevue/iconfield')['default']
|
||||
InputIcon: typeof import('primevue/inputicon')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
@@ -54,6 +57,7 @@ declare global {
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
const Checkbox: typeof import('primevue/checkbox')['default']
|
||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
const Drawer: typeof import('primevue/drawer')['default']
|
||||
@@ -61,6 +65,8 @@ declare global {
|
||||
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
const IconField: typeof import('primevue/iconfield')['default']
|
||||
const InputIcon: typeof import('primevue/inputicon')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
|
||||
@@ -551,7 +551,9 @@ export class Api<
|
||||
plansList: (params: RequestParams = {}) =>
|
||||
this.request<
|
||||
ResponseResponse & {
|
||||
data?: ModelPlan[];
|
||||
data: {
|
||||
plans: ModelPlan[];
|
||||
}
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
|
||||
25
src/components/ClientOnly.tsx
Normal file
25
src/components/ClientOnly.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// export default defineComponent((props, context) => {
|
||||
// if (typeof window === 'undefined') {
|
||||
// return () => context.slots.default ? context.slots.default() : null;
|
||||
// }
|
||||
// return () => null;
|
||||
// });
|
||||
|
||||
import { ref, onMounted } from "vue";
|
||||
const ClientOnly = defineComponent({
|
||||
name: "ClientOnly",
|
||||
setup(_p, { slots }) {
|
||||
const isClient = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true;
|
||||
});
|
||||
return () => {
|
||||
if (isClient.value) {
|
||||
return slots.default ? slots.default() : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
},
|
||||
});
|
||||
export default ClientOnly;
|
||||
@@ -15,52 +15,43 @@ const profileHoist = createStaticVNode(`<div class="h-[38px] w-[38px] rounded-fu
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</div>`, 1);
|
||||
const links = [
|
||||
{ href: "/fdsfsd", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||
{ href: "#notification", label: "Notification", icon: Bell, type: "notification", className },
|
||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' },
|
||||
];
|
||||
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
|
||||
const handleNotificationClick = (event: Event) => {
|
||||
notificationPopover.value?.toggle(event);
|
||||
};
|
||||
const links = [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' },
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen border-r border-gray-200 bg-white">
|
||||
<template v-for="i in links" :key="i.label">
|
||||
<!-- Notification button with popover -->
|
||||
<button
|
||||
name="notification"
|
||||
v-if="i.type === 'notification'"
|
||||
@click="handleNotificationClick"
|
||||
v-tooltip="i.label"
|
||||
:class="cn(i.className, 'relative', isNotificationOpen && 'bg-primary/15')"
|
||||
>
|
||||
<component :is="i.icon" class="w-6 h-6" :filled="isNotificationOpen" />
|
||||
<!-- Unread badge -->
|
||||
<span class="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white"></span>
|
||||
</button>
|
||||
<!-- Regular links -->
|
||||
<component
|
||||
v-else
|
||||
:name="i.label"
|
||||
:is="i.type === 'a' ? 'router-link' : 'div'"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}"
|
||||
v-tooltip="i.label"
|
||||
:class="cn(i.className, $route.path === i.href && 'bg-primary/15')"
|
||||
@click="i.action && i.action($event)"
|
||||
:class="cn(i.className, ($route.path === i.href || i.isActive?.value) && 'bg-primary/15')"
|
||||
>
|
||||
<component :is="i.icon" class="w-6 h-6" :filled="$route.path === i.href" />
|
||||
<component :is="i.icon" class="w-6 h-6" :filled="$route.path === i.href || i.isActive?.value" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
<ClientOnly>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
</ClientOnly>
|
||||
</header>
|
||||
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ onClickOutside(drawerRef, (event) => {
|
||||
visible.value = false;
|
||||
}
|
||||
}, {
|
||||
ignore: ['.press-animated', '[name="notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
|
||||
ignore: ['.press-animated', '[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
|
||||
});
|
||||
|
||||
const handleMarkRead = (id: string) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ app.use(cors(), async (c, next) => {
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
return fetch(url.toString(), {
|
||||
method: c.req.method,
|
||||
headers: headers,
|
||||
body: c.req.raw.body,
|
||||
@@ -48,31 +48,6 @@ app.use(cors(), async (c, next) => {
|
||||
duplex: 'half',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const newHeaders = new Headers(response.headers);
|
||||
|
||||
// Rewrite Set-Cookie to remove Domain attribute
|
||||
if (typeof response.headers.getSetCookie === 'function') {
|
||||
newHeaders.delete('set-cookie');
|
||||
const cookies = response.headers.getSetCookie();
|
||||
for (const cookie of cookies) {
|
||||
// Remove Domain=...; or Domain=... ending
|
||||
const newCookie = cookie.replace(/Domain=[^;]+;?/gi, '');
|
||||
newHeaders.append('set-cookie', newCookie);
|
||||
}
|
||||
} else {
|
||||
// Fallback for environments without getSetCookie
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
if (cookie) {
|
||||
newHeaders.set('set-cookie', cookie.replace(/Domain=[^;]+;?/gi, ''));
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders
|
||||
});
|
||||
});
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -35,7 +35,7 @@ import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import Toast from 'primevue/toast';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
@@ -44,10 +45,8 @@ import { z } from 'zod';
|
||||
|
||||
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
const initialValues = reactive({
|
||||
name: '',
|
||||
@@ -65,9 +64,7 @@ const resolver = zodResolver(
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
auth.register(values.name, values.email, values.password).catch(() => {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
|
||||
});
|
||||
auth.register(values.name, values.email, values.password);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { inject } from "vue";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
|
||||
@@ -12,7 +12,7 @@ import EditPlanDialog from './components/EditPlanDialog.vue';
|
||||
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const plans = ref<ModelPlan[]>([]);
|
||||
// const plans = ref<ModelPlan[]>([]);
|
||||
const subscribing = ref<string | null>(null);
|
||||
const showManageDialog = ref(false);
|
||||
const cancelling = ref(false);
|
||||
@@ -24,6 +24,7 @@ const paymentHistory = ref([
|
||||
{ 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' },
|
||||
]);
|
||||
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", client.plans.plansList)
|
||||
|
||||
// Computed Usage (Mock if not in store)
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||
@@ -34,27 +35,26 @@ const uploadsLimit = ref(50);
|
||||
|
||||
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
|
||||
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(plans.value)) return undefined;
|
||||
return plans.value.find(p => p.id === currentPlanId.value);
|
||||
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
|
||||
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
|
||||
});
|
||||
|
||||
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", client.plans.plansList)
|
||||
|
||||
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 });
|
||||
// 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>({});
|
||||
@@ -85,9 +85,9 @@ const savePlan = async (updatedPlan: ModelPlan) => {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to update plan', e);
|
||||
// Fallback: update local state if API is mocked/missing
|
||||
const idx = plans.value.findIndex(p => p.id === updatedPlan.id);
|
||||
const idx = data.value!.data.data.plans.findIndex(p => p.id === updatedPlan.id);
|
||||
if (idx !== -1) {
|
||||
plans.value[idx] = { ...updatedPlan };
|
||||
data.value!.data.data.plans[idx] = { ...updatedPlan };
|
||||
}
|
||||
showEditDialog.value = false;
|
||||
// alert('Note: API update failed, updated locally. ' + e.message);
|
||||
@@ -168,8 +168,8 @@ const cancelSubscription = async () => {
|
||||
</div>
|
||||
|
||||
<PlanList
|
||||
:plans="plans"
|
||||
:is-loading="isLoading"
|
||||
:plans="data?.data?.data.plans || []"
|
||||
:is-loading="!!isLoading"
|
||||
:current-plan-id="currentPlanId"
|
||||
:subscribing-plan-id="subscribing"
|
||||
:is-admin="auth.user?.role === 'admin'"
|
||||
|
||||
@@ -1,35 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } 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';
|
||||
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();
|
||||
|
||||
// Computed for display
|
||||
const joinDate = computed(() => {
|
||||
return new Date(auth.user?.created_at || Date.now()).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
});
|
||||
// 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
|
||||
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];
|
||||
// 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>
|
||||
|
||||
@@ -45,138 +70,37 @@ const formatBytes = (bytes: number) => {
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<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="md:col-span-2">
|
||||
<ProfileInfoCard
|
||||
:user="auth.user"
|
||||
@change-password="showPasswordDialog = true"
|
||||
/>
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
47
src/routes/profile/components/AccountStatusCard.vue
Normal file
47
src/routes/profile/components/AccountStatusCard.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<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 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">
|
||||
<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>
|
||||
102
src/routes/profile/components/ChangePasswordDialog.vue
Normal file
102
src/routes/profile/components/ChangePasswordDialog.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<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>
|
||||
25
src/routes/profile/components/LinkedAccountsCard.vue
Normal file
25
src/routes/profile/components/LinkedAccountsCard.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
83
src/routes/profile/components/ProfileHero.vue
Normal file
83
src/routes/profile/components/ProfileHero.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelUser } from '@/api/client';
|
||||
import Avatar from 'primevue/avatar';
|
||||
import Tag from 'primevue/tag';
|
||||
import Button from 'primevue/button';
|
||||
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 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>
|
||||
<!-- :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="secondary" 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>
|
||||
81
src/routes/profile/components/ProfileInfoCard.vue
Normal file
81
src/routes/profile/components/ProfileInfoCard.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelUser } from '@/api/client';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
defineProps<{
|
||||
user: ModelUser | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
changePassword: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
@@ -1,23 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import HardDriveUpload from '@/components/icons/HardDriveUpload.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import Upload from '@/components/icons/Upload.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import UploadModeToggle from './components/UploadModeToggle.vue';
|
||||
import InfoTip from './components/InfoTip.vue';
|
||||
import UploadDropzone from './components/UploadDropzone.vue';
|
||||
import RemoteUrlForm from './components/RemoteUrlForm.vue';
|
||||
import BulkActions from './components/BulkActions.vue';
|
||||
import UploadQueue from './components/UploadQueue.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mode = ref<'local' | 'remote'>('local');
|
||||
const modeList: { id: 'local' | 'remote'; label: string; icon: any }[] = [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'Local Upload',
|
||||
icon: HardDriveUpload
|
||||
},
|
||||
{
|
||||
id: 'remote',
|
||||
label: 'Remote URL',
|
||||
icon: LinkIcon
|
||||
}
|
||||
]
|
||||
|
||||
const handleFilesSelected = (files: FileList) => {
|
||||
console.log('Files selected:', files);
|
||||
// TODO: Handle file upload
|
||||
};
|
||||
|
||||
const handleRemoteUrls = (urls: string[]) => {
|
||||
console.log('URLs submitted:', urls);
|
||||
// TODO: Handle remote URL import
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,239 +30,20 @@ const modeList: { id: 'local' | 'remote'; label: string; icon: any }[] = [
|
||||
{ label: 'Upload Videos' }
|
||||
]" />
|
||||
<div class="flex flex-col max-w-4xl mx-auto gap-4">
|
||||
<div class="inline-flex bg-slate-100 p-1 rounded-2xl relative z-0 w-fit">
|
||||
<div
|
||||
:class="cn(':uno: absolute left-1 top-1 h-[calc(100%-8px)] w-[calc(50%-4px)] bg-white rounded-xl shadow-sm transition-all duration-300 ease-out -z-10', mode === 'local' ? 'translate-x-0' : 'translate-x-full')">
|
||||
</div>
|
||||
<button v-for="value in modeList" @click="mode = value.id"
|
||||
:class="cn('flex items-center gap-2 px-6 py-3 text-sm rounded-xl transition-colors relative z-10', mode === value.id ? 'font-semibold text-slate-900' : 'font-medium text-slate-500 hover:text-slate-900 ')">
|
||||
<component :is="value.icon" :filled="mode === value.id" class="w-5 h-5" /> {{ value.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-blue-500/30 bg-blue-500/10 p-3 flex items-start gap-3"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-info h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5"
|
||||
aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<div class="flex-1 text-sm">
|
||||
<p class="font-medium text-blue-900 dark:text-blue-100 mb-1">Tip: For fastest processing</p>
|
||||
<p class="text-blue-800 dark:text-blue-200">Upload videos in <strong>H.264 video codec + AAC
|
||||
audio codec</strong> format (e.g., MP4 with H.264/AAC). Videos in this format will be
|
||||
processed much faster (seconds instead of minutes) because they don't need re-encoding.</p>
|
||||
</div>
|
||||
</div>
|
||||
<UploadModeToggle v-model="mode" />
|
||||
<InfoTip />
|
||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
enter-to-class="opacity-100 transform translate-y-0"
|
||||
leave-active-class="transition-all duration-200 ease-in-out"
|
||||
leave-from-class="opacity-100 transform translate-y-0"
|
||||
leave-to-class="opacity-0 transform -translate-y-4" mode="out-in">
|
||||
<div v-if="mode === 'local'">
|
||||
<div class="relative group cursor-pointer">
|
||||
<input type="file" multiple
|
||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer"
|
||||
onchange="simulateUploadStart()">
|
||||
|
||||
<div
|
||||
class="bg-gradient-to-tr from-slate-50 to-white rounded-[2rem] p-16 text-center border-2 border-dashed border-slate-200 group-hover:border-success/50 group-hover:shadow-soft transition-all duration-300 relative overflow-hidden">
|
||||
|
||||
<div
|
||||
class="absolute top-0 left-0 w-64 h-64 bg-indigo-100/40 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-64 h-64 bg-blue-100/40 rounded-full blur-3xl translate-x-1/2 translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<div
|
||||
class="w-24 h-24 mb-8 rounded-3xl bg-white shadow-soft flex items-center justify-center text-accent group-hover:scale-110 group-hover:shadow-card-hover transition-all duration-300 ring-4 ring-slate-50 group-hover:ring-indigo-50">
|
||||
<Upload filled class="w-10 h-10"></Upload>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-slate-900 mb-3">Drag and drop your videos here
|
||||
|
||||
</h3>
|
||||
<p class="text-slate-500 text-base mb-8 max-w-md mx-auto leading-relaxed">Supports uploading multiple files at once. Formats MP4, MOV, MKV. Up to 10GB per file.</p>
|
||||
<span class="px-8 py-3.5 btn-lg btn-primary flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="22 -226 570 436"><path d="M56-22 32 48v-200c0-35 29-64 64-64h139c14 0 27 5 39 13l38 29c5 4 12 6 19 6h117c36 0 64 29 64 64v16H147c-41 0-78 26-91 66zm422 222H99c-33 0-56-32-45-63L102-7c6-20 24-33 45-33h379c33 0 56 32 45 63l-48 144c-6 20-25 33-45 33z" fill="#fff"/></svg>
|
||||
Choose Files
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="bg-white rounded-[2rem] shadow-soft p-10 border border-slate-100/50">
|
||||
<label class="block text-lg font-semibold text-slate-900 mb-4">Nhập đường dẫn Video</label>
|
||||
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="flex-1 relative group">
|
||||
<div
|
||||
class="absolute left-4 top-4 text-slate-400 group-focus-within:text-accent transition-colors">
|
||||
<i data-lucide="globe" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Dán một hoặc nhiều link (mỗi link một dòng)... https://drive.google.com/file/..."
|
||||
class="w-full pl-12 pr-4 py-4 h-32 bg-slate-50/50 border-2 border-slate-100 rounded-2xl focus:border-accent focus:bg-white focus:ring-0 transition-all resize-none text-slate-800 placeholder:text-slate-400 text-base leading-relaxed font-medium"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-4 py-2 rounded-full">
|
||||
<i data-lucide="info" class="w-4 h-4"></i> Tự động phát hiện Google Drive, Dropbox
|
||||
</div>
|
||||
<button onclick="simulateUploadStart()"
|
||||
class="px-8 py-3.5 bg-slate-900 hover:bg-black text-white font-medium rounded-xl shadow-xl shadow-slate-200/50 transition-all active:scale-95 flex items-center gap-2">
|
||||
<i data-lucide="sparkles" class="w-5 h-5 text-yellow-300"></i>
|
||||
Import & Upload Videos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UploadDropzone v-if="mode === 'local'" @files-selected="handleFilesSelected" />
|
||||
<RemoteUrlForm v-else @submit="handleRemoteUrls" />
|
||||
</Transition>
|
||||
<div id="bulk-actions" class="mt-10 hidden opacity-0 translate-y-4 transition-all duration-500">
|
||||
<div
|
||||
class="p-6 bg-indigo-50/50 rounded-3xl border border-indigo-100/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-900">Thiết lập nhanh</h4>
|
||||
<p class="text-slate-500 text-sm">Áp dụng cho 2 file đang chờ</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<select
|
||||
class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:border-accent outline-none transition">
|
||||
<option>Chọn chuyên mục...</option>
|
||||
<option>Học tập</option>
|
||||
<option>Giải trí</option>
|
||||
</select>
|
||||
<select
|
||||
class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:border-accent outline-none transition">
|
||||
<option>Công khai (Public)</option>
|
||||
<option>Riêng tư (Private)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BulkActions :visible="false" :pending-count="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="w-[420px] bg-gray-100 rounded-xl flex flex-col h-[calc(100svh-64px)] sticky top-16">
|
||||
|
||||
<div class="p-6 border-b border-slate-100/80 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Hàng chờ tải lên</h2>
|
||||
<p class="text-sm text-slate-500 mt-1" id="queue-status">Chưa có tác vụ nào</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
||||
<i data-lucide="list-video" class="w-5 h-5"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin p-6 space-y-5 relative" id="queue-list">
|
||||
|
||||
<div id="empty-queue"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
|
||||
<img src="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" alt="Empty"
|
||||
class="w-32 h-32 mb-4 grayscale opacity-50 drop-shadow-xl">
|
||||
<p class="text-slate-400 font-medium">Empty queue!</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="queue-item hidden bg-white rounded-2xl p-5 shadow-soft border border-slate-100/50 relative group overflow-hidden transition-all hover:shadow-md">
|
||||
<div class="flex gap-4 relative z-10">
|
||||
<div class="w-20 h-16 bg-slate-800 rounded-xl shrink-0 relative overflow-hidden shadow-sm">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10"></div>
|
||||
<img src="https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=200&q=80"
|
||||
class="w-full h-full object-cover opacity-80" alt="">
|
||||
<div class="absolute bottom-1 left-2 z-20">
|
||||
<i data-lucide="play-circle" class="w-4 h-4 text-white/90"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-0.5 flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-800 truncate">Introduction_to_React_v18.mp4</h4>
|
||||
<button
|
||||
class="text-slate-300 hover:text-red-500 transition p-1 -mr-2 -mt-2 opacity-0 group-hover:opacity-100">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1.5 font-medium">
|
||||
<span class="flex items-center gap-1.5"><span
|
||||
class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
|
||||
Uploading...</span>
|
||||
<span class="text-accent font-bold">72%</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden relative">
|
||||
<div class="absolute inset-0 bg-accent/20 animate-pulse w-full"></div>
|
||||
<div
|
||||
class="h-full bg-accent rounded-full w-[72%] relative z-10 shadow-[0_0_12px_rgba(99,102,241,0.6)] transition-all duration-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-[11px] text-slate-400 font-medium">
|
||||
<span>345 MB of 520 MB</span>
|
||||
<span>4.2 MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="queue-item hidden bg-[#F0F3FF] rounded-2xl p-5 shadow-soft border border-indigo-100/50 relative overflow-hidden group transition-all hover:shadow-md">
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.03] bg-[radial-gradient(#6366F1_1px,transparent_1px)] [background-size:16px_16px]">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 relative z-10">
|
||||
<div
|
||||
class="w-20 h-16 bg-indigo-100 rounded-xl shrink-0 flex items-center justify-center text-accent shadow-sm">
|
||||
<i data-lucide="link-2" class="w-8 h-8 opacity-80"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-1 flex flex-col justify-center">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-800 truncate">Advanced_NodeJS_Patterns.mkv</h4>
|
||||
<button
|
||||
class="text-slate-400 hover:text-red-500 transition p-1 -mr-2 -mt-2 opacity-0 group-hover:opacity-100">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-bold text-indigo-600 bg-white py-1.5 px-3 rounded-lg shadow-sm">
|
||||
<i data-lucide="loader" class="w-3.5 h-3.5 animate-spin"></i>
|
||||
Fetching from Google Drive...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t-2 border-white rounded-b-2xl shrink-0">
|
||||
<div class="flex items-center justify-between text-sm mb-4 font-medium">
|
||||
<span class="text-slate-500">Tổng dung lượng:</span>
|
||||
<span class="text-slate-900">865 MB</span>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary w-full flex items-center justify-center gap-2" id="btn-publish"
|
||||
disabled>
|
||||
<i data-lucide="check-circle-2" class="w-5 h-5"></i>
|
||||
Hoàn tất & Xuất bản (0)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<UploadQueue />
|
||||
</div>
|
||||
</template>
|
||||
36
src/routes/upload/components/BulkActions.vue
Normal file
36
src/routes/upload/components/BulkActions.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
pendingCount?: number;
|
||||
visible?: boolean;
|
||||
}>();
|
||||
|
||||
const category = ref('');
|
||||
const visibility = ref('public');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="bulk-actions" class="mt-10" :class="{ 'hidden opacity-0 translate-y-4': !visible }"
|
||||
style="transition: all 500ms;">
|
||||
<div class="p-6 bg-indigo-50/50 rounded-3xl border border-indigo-100/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-900">Quick Settings</h4>
|
||||
<p class="text-slate-500 text-sm">Apply to {{ pendingCount || 0 }} pending files</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<select v-model="category"
|
||||
class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:border-accent outline-none transition">
|
||||
<option value="">Select category...</option>
|
||||
<option value="learning">Learning</option>
|
||||
<option value="entertainment">Entertainment</option>
|
||||
</select>
|
||||
<select v-model="visibility"
|
||||
class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:border-accent outline-none transition">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
src/routes/upload/components/InfoTip.vue
Normal file
20
src/routes/upload/components/InfoTip.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-2xl border border-blue-500/30 bg-blue-500/10 p-3 flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-info h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<div class="flex-1 text-sm">
|
||||
<p class="font-medium text-blue-900 dark:text-blue-100 mb-1">Tip: For fastest processing</p>
|
||||
<p class="text-blue-800 dark:text-blue-200">Upload videos in <strong>H.264 video codec + AAC
|
||||
audio codec</strong> format (e.g., MP4 with H.264/AAC). Videos in this format will be
|
||||
processed much faster (seconds instead of minutes) because they don't need re-encoding.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
66
src/routes/upload/components/RemoteUrlForm.vue
Normal file
66
src/routes/upload/components/RemoteUrlForm.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const urls = ref('');
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [urls: string[]];
|
||||
}>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const urlList = urls.value
|
||||
.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
if (urlList.length > 0) {
|
||||
emit('submit', urlList);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-[2rem] shadow-soft p-10 border border-slate-100/50">
|
||||
<label class="block text-lg font-semibold text-slate-900 mb-4">Enter Video URL</label>
|
||||
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="flex-1 relative group">
|
||||
<div class="absolute left-4 top-4 text-slate-400 group-focus-within:text-accent transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
</div>
|
||||
<textarea v-model="urls"
|
||||
placeholder="Paste one or more links (one per line)... https://drive.google.com/file/..."
|
||||
class="w-full pl-12 pr-4 py-4 h-32 bg-slate-50/50 border-2 border-slate-100 rounded-2xl focus:border-accent focus:bg-white focus:ring-0 transition-all resize-none text-slate-800 placeholder:text-slate-400 text-base leading-relaxed font-medium"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-4 py-2 rounded-full">
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
Auto-detect Google Drive, Dropbox
|
||||
</div>
|
||||
<button @click="handleSubmit"
|
||||
class="px-8 py-3.5 bg-slate-900 hover:bg-black text-white font-medium rounded-xl shadow-xl shadow-slate-200/50 transition-all active:scale-95 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-yellow-300" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path
|
||||
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
|
||||
<path d="M20 3v4" />
|
||||
<path d="M22 5h-4" />
|
||||
<path d="M4 17v2" />
|
||||
<path d="M5 18H3" />
|
||||
</svg>
|
||||
Import & Upload Videos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
55
src/routes/upload/components/UploadDropzone.vue
Normal file
55
src/routes/upload/components/UploadDropzone.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
filesSelected: [files: FileList];
|
||||
}>();
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
emit('filesSelected', input.files);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative group cursor-pointer">
|
||||
<input type="file" multiple accept="video/*"
|
||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||
|
||||
<div
|
||||
class="bg-gradient-to-tr from-slate-50 to-white rounded-[2rem] p-16 text-center border-2 border-dashed border-slate-200 group-hover:border-success/50 group-hover:shadow-soft transition-all duration-300 relative overflow-hidden">
|
||||
|
||||
<div
|
||||
class="absolute top-0 left-0 w-64 h-64 bg-indigo-100/40 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-64 h-64 bg-blue-100/40 rounded-full blur-3xl translate-x-1/2 translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<div
|
||||
class="w-24 h-24 mb-8 rounded-3xl bg-white shadow-soft flex items-center justify-center text-accent group-hover:scale-110 group-hover:shadow-card-hover transition-all duration-300 ring-4 ring-slate-50 group-hover:ring-indigo-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" 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>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-slate-900 mb-3">Drag and drop your videos here</h3>
|
||||
<p class="text-slate-500 text-base mb-8 max-w-md mx-auto leading-relaxed">
|
||||
Supports uploading multiple files at once. Formats MP4, MOV, MKV. Up to 10GB per file.
|
||||
</p>
|
||||
<span class="px-8 py-3.5 btn-lg btn-primary flex items-center gap-2">
|
||||
<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="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
Choose Files
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
43
src/routes/upload/components/UploadModeToggle.vue
Normal file
43
src/routes/upload/components/UploadModeToggle.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: 'local' | 'remote';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: 'local' | 'remote'];
|
||||
}>();
|
||||
|
||||
const modeList: { id: 'local' | 'remote'; label: string; icon: string }[] = [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'Local Upload',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 1 7.38 16.75"/><path d="M12 8v8"/><path d="m8 12 4-4 4 4"/><path d="M2.5 8.875a10 10 0 0 0-.5 3"/><path d="M2.83 16a10 10 0 0 0 2.43 3.4"/><path d="M4.636 5.235a10 10 0 0 1 .891-.857"/><rect width="6" height="6" x="16" y="16" rx="1"/></svg>`
|
||||
},
|
||||
{
|
||||
id: 'remote',
|
||||
label: 'Remote URL',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`
|
||||
}
|
||||
];
|
||||
|
||||
const mode = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex bg-slate-100 p-1 rounded-2xl relative z-0 w-fit">
|
||||
<div
|
||||
:class="cn(':uno: absolute left-1 top-1 h-[calc(100%-8px)] w-[calc(50%-4px)] bg-white rounded-xl shadow-sm transition-all duration-300 ease-out -z-10', mode === 'local' ? 'translate-x-0' : 'translate-x-full')">
|
||||
</div>
|
||||
<button v-for="item in modeList" :key="item.id" @click="mode = item.id"
|
||||
:class="cn('flex items-center gap-2 px-6 py-3 text-sm rounded-xl transition-colors relative z-10', mode === item.id ? 'font-semibold text-slate-900' : 'font-medium text-slate-500 hover:text-slate-900 ')">
|
||||
<span class="w-5 h-5" v-html="item.icon"></span>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
69
src/routes/upload/components/UploadQueue.vue
Normal file
69
src/routes/upload/components/UploadQueue.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import UploadQueueItem, { type QueueItem } from './UploadQueueItem.vue';
|
||||
|
||||
defineProps<{
|
||||
items?: QueueItem[];
|
||||
totalSize?: string;
|
||||
completeCount?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
removeItem: [id: string];
|
||||
publish: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="w-[420px] bg-gray-100 rounded-xl flex flex-col h-[calc(100svh-64px)] sticky top-16">
|
||||
<div class="p-6 border-b border-slate-100/80 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
|
||||
<p class="text-sm text-slate-500 mt-1" id="queue-status">
|
||||
{{ items?.length ? `${items.length} task(s)` : 'No tasks yet' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
||||
<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="M12 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M12 18H3" />
|
||||
<path d="m16 12 5 3-5 3v-6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin p-6 space-y-5 relative" id="queue-list">
|
||||
<div v-if="!items?.length" id="empty-queue"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 mb-4 text-slate-300" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 21V9" />
|
||||
</svg>
|
||||
<p class="text-slate-400 font-medium">Empty queue!</p>
|
||||
</div>
|
||||
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item"
|
||||
@remove="emit('removeItem', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t-2 border-white rounded-b-2xl shrink-0">
|
||||
<div class="flex items-center justify-between text-sm mb-4 font-medium">
|
||||
<span class="text-slate-500">Total size:</span>
|
||||
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
|
||||
</div>
|
||||
<button @click="emit('publish')"
|
||||
class="btn btn-outline-primary w-full flex items-center justify-center gap-2" id="btn-publish"
|
||||
:disabled="!completeCount">
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
Complete & Publish ({{ completeCount || 0 }})
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
119
src/routes/upload/components/UploadQueueItem.vue
Normal file
119
src/routes/upload/components/UploadQueueItem.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
status: 'uploading' | 'processing' | 'fetching' | 'complete' | 'error';
|
||||
progress?: number;
|
||||
uploaded?: string;
|
||||
total?: string;
|
||||
speed?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
item: QueueItem;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [id: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Local Upload Item -->
|
||||
<div v-if="item.type === 'local'"
|
||||
class="bg-white rounded-2xl p-5 shadow-soft border border-slate-100/50 relative group overflow-hidden transition-all hover:shadow-md">
|
||||
<div class="flex gap-4 relative z-10">
|
||||
<div class="w-20 h-16 bg-slate-800 rounded-xl shrink-0 relative overflow-hidden shadow-sm">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10"></div>
|
||||
<img v-if="item.thumbnail" :src="item.thumbnail" class="w-full h-full object-cover opacity-80" alt="">
|
||||
<div class="absolute bottom-1 left-2 z-20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white/90" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-0.5 flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-800 truncate">{{ item.name }}</h4>
|
||||
<button @click="emit('remove', item.id)"
|
||||
class="text-slate-300 hover:text-red-500 transition p-1 -mr-2 -mt-2 opacity-0 group-hover:opacity-100">
|
||||
<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">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1.5 font-medium">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
|
||||
Uploading...
|
||||
</span>
|
||||
<span class="text-accent font-bold">{{ item.progress || 0 }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden relative">
|
||||
<div class="absolute inset-0 bg-accent/20 animate-pulse w-full"></div>
|
||||
<div class="h-full bg-accent rounded-full relative z-10 shadow-[0_0_12px_rgba(99,102,241,0.6)] transition-all duration-500"
|
||||
:style="{ width: `${item.progress || 0}%` }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-[11px] text-slate-400 font-medium">
|
||||
<span>{{ item.uploaded || '0 MB' }} of {{ item.total || '0 MB' }}</span>
|
||||
<span>{{ item.speed || '0 MB/s' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Fetch Item -->
|
||||
<div v-else
|
||||
class="bg-[#F0F3FF] rounded-2xl p-5 shadow-soft border border-indigo-100/50 relative overflow-hidden group transition-all hover:shadow-md">
|
||||
<div class="absolute inset-0 opacity-[0.03] bg-[radial-gradient(#6366F1_1px,transparent_1px)] [background-size:16px_16px]">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 relative z-10">
|
||||
<div class="w-20 h-16 bg-indigo-100 rounded-xl shrink-0 flex items-center justify-center text-accent shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 opacity-80" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
||||
<line x1="8" x2="16" y1="12" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-1 flex flex-col justify-center">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-800 truncate">{{ item.name }}</h4>
|
||||
<button @click="emit('remove', item.id)"
|
||||
class="text-slate-400 hover:text-red-500 transition p-1 -mr-2 -mt-2 opacity-0 group-hover:opacity-100">
|
||||
<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">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<div class="flex items-center gap-2 text-xs font-bold text-indigo-600 bg-white py-1.5 px-3 rounded-lg shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Fetching from Google Drive...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
if (r.data) {
|
||||
user.value = r.data.user as ModelUser;
|
||||
}
|
||||
}).catch(() => {}).finally(() => {
|
||||
}).catch(() => { }).finally(() => {
|
||||
initialized.value = true;
|
||||
});
|
||||
// client.request<
|
||||
@@ -53,8 +53,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// So: response.data (HttpResponse body) -> .data (ResponseResponse payload)
|
||||
|
||||
const body = response.data as any; // Cast to access potential 'data' property if types are loose
|
||||
console.log("body", body);
|
||||
if (body && body.data) {
|
||||
user.value = body.data;
|
||||
user.value = body.data.user;
|
||||
router.push('/');
|
||||
} else {
|
||||
throw new Error('Login failed: No user data received');
|
||||
@@ -103,17 +104,52 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
async function updateProfile(data: { username?: string; email?: string }) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await client.auth.logoutCreate();
|
||||
user.value = null;
|
||||
router.push('/login');
|
||||
const response = await client.request<
|
||||
ResponseResponse & { data?: ModelUser },
|
||||
ResponseResponse
|
||||
>({
|
||||
path: '/me',
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
format: 'json'
|
||||
});
|
||||
|
||||
const body = response.data as any;
|
||||
if (body && body.data) {
|
||||
user.value = { ...user.value, ...body.data };
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error('Logout error', e);
|
||||
// Force local logout anyway
|
||||
user.value = null;
|
||||
router.push('/login');
|
||||
console.error('Update profile error', e);
|
||||
error.value = 'Failed to update profile: ' + (e.message || 'Unknown error');
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword: string, newPassword: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await client.request<ResponseResponse, ResponseResponse>({
|
||||
path: '/auth/change-password',
|
||||
method: 'POST',
|
||||
body: {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
},
|
||||
format: 'json'
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error('Change password error', e);
|
||||
error.value = 'Failed to change password: ' + (e.message || 'Unknown error');
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -128,7 +164,22 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
login,
|
||||
loginWithGoogle,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
logout: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await client.auth.logoutCreate();
|
||||
user.value = null;
|
||||
router.push('/login');
|
||||
} catch (e: any) {
|
||||
console.error('Logout error', e);
|
||||
user.value = null;
|
||||
router.push('/login');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
$reset: () => {
|
||||
user.value = null;
|
||||
loading.value = false;
|
||||
|
||||
Reference in New Issue
Block a user