develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
25 changed files with 982 additions and 482 deletions
Showing only changes of commit b87d18576b - Show all commits

6
components.d.ts vendored
View File

@@ -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']

View File

@@ -551,7 +551,9 @@ export class Api<
plansList: (params: RequestParams = {}) =>
this.request<
ResponseResponse & {
data?: ModelPlan[];
data: {
plans: ModelPlan[];
}
},
ResponseResponse
>({

View 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;

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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 });

View File

@@ -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";

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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'"

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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)...&#10;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 (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 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>

View 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>

View 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>

View 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)...&#10;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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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;