feat: thêm ít avatar

This commit is contained in:
ngthanhvu
2026-07-23 11:00:05 -04:00
parent 629d5d7d2d
commit 70d8fc5bfa
62 changed files with 1401 additions and 158 deletions
+33 -16
View File
@@ -27,6 +27,11 @@ const displayName = computed(() => {
const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase()) const displayInitial = computed(() => displayName.value.charAt(0).toUpperCase())
const avatarUrl = computed(() => {
if (user.value?.avatar) return user.value.avatar
return null
})
const navItems = [ const navItems = [
{ label: 'Trang chủ', to: '/' }, { label: 'Trang chủ', to: '/' },
{ label: 'Duyệt phim', to: '/phim' }, { label: 'Duyệt phim', to: '/phim' },
@@ -111,38 +116,49 @@ watch(() => route.path, () => {
<div class="group/member relative hidden shrink-0 items-center gap-2.5 md:flex"> <div class="group/member relative hidden shrink-0 items-center gap-2.5 md:flex">
<button type="button" <button type="button"
class="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-white px-2.5 pl-1 text-xs font-black text-slate-950 shadow-xl shadow-black/20 transition hover:bg-yellow-100" class="inline-flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full bg-[#F2F4F7] px-4 text-sm font-semibold text-[#1c1c1c] shadow-sm transition hover:bg-white hover:shadow-md"
aria-label="Tài khoản thành viên" @click="handleMemberClick"> aria-label="Tài khoản thành viên" @click="handleMemberClick">
<template v-if="user"> <template v-if="user">
<span <img v-if="avatarUrl" :src="avatarUrl" :alt="displayName" class="size-7 rounded-full object-cover ring-2 ring-[#FFD166]/20">
class="grid size-7 place-items-center rounded-full bg-yellow-400/20 text-xs font-black text-yellow-400"> <span v-else
class="grid size-7 place-items-center rounded-full bg-[#FFD166]/20 text-xs font-bold text-[#FFD166]">
{{ displayInitial }} {{ displayInitial }}
</span> </span>
<span class="max-w-24 truncate">{{ displayName }}</span> <span class="max-w-24 truncate">{{ displayName }}</span>
</template> </template>
<template v-else> <template v-else>
<User class="ml-1 size-4 fill-current" /> <svg class="size-5 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<span>Đăng nhập</span> <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<span>Thành viên</span>
</template> </template>
</button> </button>
<Transition name="member-menu"> <Transition name="member-menu">
<div v-if="user && memberMenuOpen" <div v-if="user && memberMenuOpen"
class="absolute right-0 top-[calc(100%+0.5rem)] w-52 overflow-hidden rounded-xl border border-white/8 bg-[#101116]/95 py-1.5 text-sm text-slate-300 shadow-xl shadow-black/50 backdrop-blur-xl"> class="absolute right-0 top-[calc(100%+0.5rem)] w-56 overflow-hidden rounded-2xl border border-white/[0.08] bg-[#12121a]/95 py-2 text-sm shadow-[0_20px_50px_rgba(0,0,0,0.5)] backdrop-blur-xl">
<div class="border-b border-white/6 px-4 py-2.5"> <div class="border-b border-white/[0.06] px-4 py-3">
<p class="truncate text-xs font-semibold text-white">{{ displayName }}</p> <div class="flex items-center gap-2.5">
<p class="truncate text-[11px] text-slate-500">{{ user.email }}</p> <img v-if="avatarUrl" :src="avatarUrl" :alt="displayName" class="size-8 rounded-full object-cover ring-2 ring-[#FFD166]/20">
<div v-else class="grid size-8 shrink-0 place-items-center rounded-full bg-[#FFD166]/10">
<span class="text-xs font-bold text-[#FFD166]">{{ displayInitial }}</span>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ displayName }}</p>
<p class="truncate text-xs text-white/40">{{ user.email }}</p>
</div>
</div>
</div> </div>
<NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to" <NuxtLink v-for="item in memberMenuItems" :key="item.label" :to="item.to"
class="flex h-9 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/6 hover:text-white"> class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 transition hover:bg-white/[0.04] hover:text-white">
<component :is="item.icon" class="size-3.5 shrink-0" /> <component :is="item.icon" class="size-4 shrink-0 text-white/50" />
<span class="text-[13px] font-medium">{{ item.label }}</span> <span class="text-[13px] font-medium text-white/70">{{ item.label }}</span>
</NuxtLink> </NuxtLink>
<div class="my-1 border-t border-white/6" /> <div class="my-1.5 border-t border-white/[0.06]" />
<button type="button" <button type="button"
class="flex h-9 w-full cursor-pointer items-center gap-2.5 px-4 text-[13px] font-medium text-red-400/80 transition hover:bg-red-400/8 hover:text-red-400" class="flex h-10 w-full cursor-pointer items-center gap-2.5 px-4 text-[13px] font-medium text-red-400/70 transition hover:bg-red-400/8 hover:text-red-400"
@click="handleLogout"> @click="handleLogout">
<LogOut class="size-3.5 shrink-0" /> <LogOut class="size-4 shrink-0" />
Đăng xuất Đăng xuất
</button> </button>
</div> </div>
@@ -171,7 +187,8 @@ watch(() => route.path, () => {
</div> </div>
<div v-if="user" class="flex items-center gap-3 border-b border-white/10 px-4 py-4"> <div v-if="user" class="flex items-center gap-3 border-b border-white/10 px-4 py-4">
<span <img v-if="avatarUrl" :src="avatarUrl" :alt="displayName" class="size-9 rounded-full object-cover ring-2 ring-[#FFD166]/20">
<span v-else
class="grid size-9 place-items-center rounded-full bg-yellow-400/10 text-sm font-black text-yellow-400"> class="grid size-9 place-items-center rounded-full bg-yellow-400/10 text-sm font-black text-yellow-400">
{{ displayInitial }} {{ displayInitial }}
</span> </span>
+256 -80
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check, Eye, EyeOff, LockKeyhole, Mail, UserRound, X } from 'lucide-vue-next' import { ArrowLeft, Check, Eye, EyeOff, KeyRound, LockKeyhole, Mail, UserRound, X } from 'lucide-vue-next'
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const { fetchUser } = useAuth() const { fetchUser } = useAuth()
const mode = ref<'login' | 'register'>('login') const mode = ref<'login' | 'register' | 'forgot'>('login')
const name = ref('') const name = ref('')
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
@@ -38,7 +38,13 @@ async function handleSubmit() {
success.value = '' success.value = ''
loading.value = true loading.value = true
try { try {
if (mode.value === 'login') { if (mode.value === 'forgot') {
await $fetch('/api/auth/forgot-password', {
method: 'POST',
body: { email: email.value },
})
success.value = 'Đã gửi link đặt lại mật khẩu! Kiểm tra email của bạn.'
} else if (mode.value === 'login') {
await $fetch('/api/auth/login', { await $fetch('/api/auth/login', {
method: 'POST', method: 'POST',
body: { email: email.value, password: password.value }, body: { email: email.value, password: password.value },
@@ -52,9 +58,9 @@ async function handleSubmit() {
success.value = 'Đăng ký thành công!' success.value = 'Đăng ký thành công!'
} }
await fetchUser() await fetchUser()
setTimeout(() => closeModal(), 1000) setTimeout(() => closeModal(), mode.value === 'login' ? 800 : 1500)
} catch (err: any) { } catch (err: any) {
error.value = err?.data?.message || (mode.value === 'login' ? 'Đăng nhập thất bại' : 'Đăng ký thất bại') error.value = err?.data?.message || (mode.value === 'login' ? 'Đăng nhập thất bại' : mode.value === 'forgot' ? 'Gửi yêu cầu thất bại' : 'Đăng ký thất bại')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -71,89 +77,204 @@ watch(open, (val) => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="auth-modal"> <Transition name="auth-modal">
<div v-if="open" class="fixed inset-0 z-70 grid place-items-center px-3"> <div v-if="open" class="auth-modal-overlay fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="closeModal" /> <div class="auth-modal-backdrop absolute inset-0 bg-[#0a0a0f]/90 backdrop-blur-md" @click="closeModal" />
<div class="relative w-full max-w-md overflow-hidden rounded-xl border border-white/10 bg-slate-900 shadow-2xl">
<div class="auth-modal-content relative w-full max-w-[820px] overflow-hidden rounded-[20px] border border-white/[0.08] bg-[#12121a] shadow-[0_25px_60px_rgba(0,0,0,0.5)]">
<button type="button" <button type="button"
class="absolute right-3 top-3 z-10 grid size-8 place-items-center rounded-full text-white transition hover:bg-white/10" class="absolute right-4 top-4 z-20 grid h-8 w-8 place-items-center rounded-full bg-black/20 text-white/70 backdrop-blur-sm transition hover:bg-black/40 hover:text-white"
@click="closeModal"> @click="closeModal">
<X class="size-5" /> <X class="size-4" />
</button> </button>
<div class="border-b border-white/10 p-6 pb-5"> <div class="flex flex-col md:flex-row">
<h2 class="text-2xl font-black text-white"> <div class="w-full md:w-1/2">
{{ mode === 'login' ? 'Đăng nhập' : 'Đăng ký' }} <div class="relative h-1.5 bg-gradient-to-r from-[#FFD166] via-[#FFB800] to-[#FFD166] md:hidden" />
</h2>
<p class="mt-1 text-sm text-slate-400">
<template v-if="mode === 'login'">
Chào mừng bạn quay trở lại CineK
</template>
<template v-else>
Tạo tài khoản CineK miễn phí
</template>
</p>
<div class="mt-4 flex gap-1 rounded-lg bg-white/5 p-1"> <div class="p-6 pb-4">
<button type="button" <div v-if="mode !== 'forgot'" class="mb-1 flex items-center gap-2.5">
class="flex-1 rounded-md px-3 py-2 text-sm font-semibold transition" <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFD166]/10">
:class="mode === 'login' ? 'bg-yellow-400 text-slate-950' : 'text-slate-400 hover:text-white'" <svg class="size-5 text-[#FFD166]" fill="currentColor" viewBox="0 0 24 24">
@click="switchMode('login')"> <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
Đăng nhập </svg>
</button> </div>
<button type="button" <div>
class="flex-1 rounded-md px-3 py-2 text-sm font-semibold transition" <h2 class="text-xl font-bold text-white">
:class="mode === 'register' ? 'bg-yellow-400 text-slate-950' : 'text-slate-400 hover:text-white'" {{ mode === 'login' ? 'Đăng nhập' : 'Đăng ký' }}
@click="switchMode('register')"> </h2>
Đăng <p class="text-xs text-white/40">
</button> <template v-if="mode === 'login'">
</div> Chào mừng bạn quay trở lại!
</div> </template>
<template v-else>
Tạo tài khoản miễn phí
</template>
</p>
</div>
</div>
<form class="p-6 pt-5" @submit.prevent="handleSubmit"> <div v-if="mode !== 'forgot'" class="mt-5 flex gap-1 rounded-full bg-white/[0.04] p-1">
<div class="space-y-3"> <button type="button"
<div v-if="mode === 'register'"> class="relative flex-1 rounded-full px-4 py-2.5 text-sm font-semibold transition-all duration-200"
<div class="relative"> :class="mode === 'login' ? 'bg-[#FFD166] text-[#0f111a] shadow-[0_4px_15px_rgba(255,209,102,0.25)]' : 'text-white/50 hover:text-white/80'"
<UserRound class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> @click="switchMode('login')">
<input v-model="name" type="text" placeholder="Tên hiển thị" Đăng nhập
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50"> </button>
<button type="button"
class="relative flex-1 rounded-full px-4 py-2.5 text-sm font-semibold transition-all duration-200"
:class="mode === 'register' ? 'bg-[#FFD166] text-[#0f111a] shadow-[0_4px_15px_rgba(255,209,102,0.25)]' : 'text-white/50 hover:text-white/80'"
@click="switchMode('register')">
Đăng
</button>
</div>
<div v-if="mode === 'forgot'">
<button type="button"
class="mb-3 flex items-center gap-1.5 text-sm text-white/50 transition hover:text-white"
@click="mode = 'login'">
<ArrowLeft class="size-3.5" />
Quay lại đăng nhập
</button>
<div class="flex items-center gap-2.5">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#FFD166]/10">
<KeyRound class="size-5 text-[#FFD166]" />
</div>
<div>
<h2 class="text-xl font-bold text-white">Quên mật khẩu</h2>
<p class="text-xs text-white/40">Nhập email để nhận link đặt lại mật khẩu</p>
</div>
</div>
</div> </div>
</div> </div>
<div class="relative"> <form class="px-6 pb-6 pt-2" @submit.prevent="handleSubmit">
<Mail class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <Transition name="form-slide" mode="out-in">
<input v-model="email" type="email" required placeholder="Email" <div v-if="mode === 'forgot'" :key="'forgot'" class="space-y-3">
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50"> <div class="relative">
</div> <Mail class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="email" type="email" required placeholder="Email của bạn"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
</div>
</div>
<div v-else :key="mode" class="space-y-3">
<Transition name="form-field">
<div v-if="mode === 'register'" class="relative">
<UserRound class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="name" type="text" placeholder="Tên hiển thị"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
</div>
</Transition>
<div class="relative"> <div class="relative">
<LockKeyhole class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /> <Mail class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6" <input v-model="email" type="email" required placeholder="Email hoặc số điện thoại"
:placeholder="mode === 'login' ? 'Mật khẩu' : 'Ít nhất 6 ký tự'" class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
class="h-11 w-full rounded-lg border border-white/10 bg-white/5 pl-10 pr-10 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50"> </div>
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-white" <div class="relative">
@click="showPassword = !showPassword"> <LockKeyhole class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<EyeOff v-if="showPassword" class="size-4" /> <input v-model="password" :type="showPassword ? 'text' : 'password'" required minlength="6"
<Eye v-else class="size-4" /> :placeholder="mode === 'login' ? 'Mật khẩu' : 'Mật khẩu (tối thiểu 6 ký tự)'"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-11 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
@click="showPassword = !showPassword">
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</div>
</Transition>
<button type="submit"
class="auth-submit-btn mt-5 inline-flex h-12 w-full items-center justify-center rounded-xl bg-[#FFD166] text-sm font-bold text-[#0f111a] transition-all hover:bg-[#FFC845] hover:shadow-[0_8px_25px_rgba(255,209,102,0.3)] active:scale-[0.98] disabled:opacity-50 disabled:hover:shadow-none"
:disabled="loading">
<svg v-if="loading" class="mr-2 size-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{{ loading ? 'Đang xử lý...' : (mode === 'login' ? 'Đăng nhập' : mode === 'forgot' ? 'Gửi link đặt lại mật khẩu' : 'Tạo tài khoản') }}
</button> </button>
</div>
<div v-if="mode === 'login'" class="mt-3 text-right">
<button type="button"
class="text-xs text-white/40 transition hover:text-[#FFD166]"
@click="mode = 'forgot'">
Quên mật khẩu?
</button>
</div>
<div v-if="mode !== 'forgot'" class="mt-5 flex items-center gap-3">
<div class="h-px flex-1 bg-white/[0.08]" />
<span class="text-xs text-white/30">hoặc</span>
<div class="h-px flex-1 bg-white/[0.08]" />
</div>
<div v-if="mode !== 'forgot'" class="mt-4 grid grid-cols-2 gap-2.5">
<button type="button"
class="flex h-11 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.08] hover:text-white">
<svg class="size-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path 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" fill="#34A853"/>
<path 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" fill="#FBBC05"/>
<path 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" fill="#EA4335"/>
</svg>
Google
</button>
<button type="button"
class="flex h-11 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.08] hover:text-white">
<svg class="size-4" fill="#1877F2" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Facebook
</button>
</div>
<Transition name="form-message">
<p v-if="error" class="mt-4 flex items-center justify-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
{{ error }}
</p>
</Transition>
<Transition name="form-message">
<p v-if="success" class="mt-4 flex items-center justify-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
<Check class="size-4 shrink-0" />
{{ success }}
</p>
</Transition>
</form>
</div> </div>
<button type="submit" <div class="auth-modal-image relative hidden overflow-hidden md:block md:w-1/2">
class="mt-5 inline-flex h-11 w-full items-center justify-center rounded-lg bg-yellow-400 text-sm font-black text-slate-950 transition hover:bg-yellow-300 disabled:opacity-50" <div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] via-[#16213e] to-[#0f3460]" />
:disabled="loading"> <div class="absolute inset-0 opacity-30">
{{ loading ? 'Đang xử lý...' : (mode === 'login' ? 'Đăng nhập' : 'Đăng ký') }} <div class="absolute top-1/4 left-1/4 h-32 w-32 rounded-full bg-[#FFD166]/20 blur-3xl" />
</button> <div class="absolute bottom-1/3 right-1/4 h-40 w-40 rounded-full bg-[#e94560]/20 blur-3xl" />
<div class="absolute top-1/2 right-1/3 h-24 w-24 rounded-full bg-[#FFD166]/15 blur-2xl" />
<p v-if="error" class="mt-3 rounded-lg bg-red-500/12 p-3 text-center text-sm text-red-300"> </div>
{{ error }} <div class="absolute inset-0 flex flex-col items-center justify-center p-8">
</p> <div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#FFD166]/10 backdrop-blur-sm">
<svg class="size-8 text-[#FFD166]" fill="currentColor" viewBox="0 0 24 24">
<p v-if="success" class="mt-3 flex items-center justify-center gap-2 rounded-lg bg-green-500/12 p-3 text-center text-sm font-semibold text-green-300"> <path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
<Check class="size-4" /> </svg>
{{ success }} </div>
</p> <p class="text-center text-lg font-bold text-white drop-shadow-lg">
</form> Khám phá hàng ngàn bộ phim hấp dẫn
</p>
<p class="mt-2 text-center text-sm text-white/60">
Đăng nhập để trải nghiệm trọn vẹn
</p>
<div class="mt-6 flex gap-2">
<span class="h-1.5 w-8 rounded-full bg-[#FFD166]" />
<span class="h-1.5 w-4 rounded-full bg-[#FFD166]/40" />
<span class="h-1.5 w-4 rounded-full bg-[#FFD166]/40" />
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -161,13 +282,68 @@ watch(open, (val) => {
</template> </template>
<style scoped> <style scoped>
.auth-modal-enter-active, .auth-modal-overlay {
.auth-modal-leave-active { animation: fadeIn 0.2s ease-out;
transition: opacity 0.18s ease;
} }
.auth-modal-enter-from, .auth-modal-content {
.auth-modal-leave-to { animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.auth-modal-backdrop {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.form-slide-enter-active,
.form-slide-leave-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.form-slide-enter-from {
opacity: 0; opacity: 0;
transform: translateX(10px);
}
.form-slide-leave-to {
opacity: 0;
transform: translateX(-10px);
}
.form-field-enter-active,
.form-field-leave-active {
transition: all 0.2s ease;
}
.form-field-enter-from,
.form-field-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.form-message-enter-active,
.form-message-leave-active {
transition: all 0.25s ease;
}
.form-message-enter-from,
.form-message-leave-to {
opacity: 0;
transform: translateY(-8px);
} }
</style> </style>
+11
View File
@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path !== '/thanh-vien') return
const { data: user, error } = await useFetch('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
if (error.value?.statusCode === 401 || !user.value) {
return navigateTo('/')
}
})
+526 -62
View File
@@ -1,21 +1,205 @@
<script setup lang="ts"> <script setup lang="ts">
import { Clock3, Heart, Mail, UserRound } from 'lucide-vue-next' import { ArrowLeft, Camera, Check, Clock3, Eye, EyeOff, Heart, KeyRound, LogOut, LockKeyhole, Mail, Pencil, Settings, UserRound, X } from 'lucide-vue-next'
definePageMeta({ middleware: 'auth' })
const authOpen = ref(false)
const { user, logout: doLogout, fetchUser } = useAuth() const { user, logout: doLogout, fetchUser } = useAuth()
await fetchUser() await fetchUser()
const activeTab = ref('account')
const showPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordError = ref('')
const passwordSuccess = ref('')
const passwordLoading = ref(false)
const avatarModalOpen = ref(false)
const avatarLoading = ref(false)
const avatarSuccess = ref('')
const avatarError = ref('')
const editName = ref(false)
const displayNameInput = ref('')
const nameLoading = ref(false)
const nameError = ref('')
const nameSuccess = ref('')
const avatarCategories = [
{ id: 'meme', label: 'Meme' },
{ id: 'hoat-hinh', label: 'Hoạt hình' },
{ id: 'viet-nam', label: 'Việt Nam' },
]
const selectedAvatarCategory = ref('meme')
const memberName = computed(() => { const memberName = computed(() => {
if (!user.value) return 'Thành viên' if (!user.value) return 'Thành viên'
return user.value.name || user.value.email?.split('@')[0] || 'Thành viên' return user.value.name || user.value.email?.split('@')[0] || 'Thành viên'
}) })
const memberInitial = computed(() => memberName.value.charAt(0).toUpperCase()) const avatarUrl = computed(() => {
if (user.value?.avatar) return user.value.avatar
return null
})
const avatarGrid = computed(() => {
const categoryMap: Record<string, string[]> = {
meme: ['01', '02', '03', '04', '05', '06', '07', '08'],
'hoat-hinh': ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30'],
'viet-nam': ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16'],
}
const nums = categoryMap[selectedAvatarCategory.value] || []
return nums.map(n => `/avatars/${selectedAvatarCategory.value}/${n}.jpg`)
})
const sidebarItems = [
{ id: 'account', label: 'Quản lý tài khoản', icon: Settings },
{ id: 'password', label: 'Đổi mật khẩu', icon: KeyRound },
{ id: 'history', label: 'Lịch sử xem', icon: Clock3 },
{ id: 'favorites', label: 'Phim yêu thích', icon: Heart },
]
async function handleLogout() { async function handleLogout() {
await doLogout() await doLogout()
} }
async function handleChangePassword() {
passwordError.value = ''
passwordSuccess.value = ''
passwordLoading.value = true
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
passwordError.value = 'Vui lòng điền đầy đủ thông tin'
passwordLoading.value = false
return
}
if (newPassword.value.length < 6) {
passwordError.value = 'Mật khẩu mới phải có ít nhất 6 ký tự'
passwordLoading.value = false
return
}
if (newPassword.value !== confirmPassword.value) {
passwordError.value = 'Mật khẩu xác nhận không khớp'
passwordLoading.value = false
return
}
try {
await $fetch('/api/auth/change-password', {
method: 'POST',
body: {
currentPassword: currentPassword.value,
newPassword: newPassword.value,
},
})
passwordSuccess.value = 'Đổi mật khẩu thành công!'
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
} catch (err: any) {
passwordError.value = err?.data?.message || 'Đổi mật khẩu thất bại'
} finally {
passwordLoading.value = false
}
}
async function selectAvatar(avatar: string) {
avatarLoading.value = true
avatarError.value = ''
avatarSuccess.value = ''
try {
await $fetch('/api/auth/avatar', {
method: 'PUT',
body: { avatar: avatar.startsWith('/') ? avatar.slice(1) : avatar },
})
avatarSuccess.value = 'Cập nhật avatar thành công!'
await fetchUser()
setTimeout(() => {
avatarModalOpen.value = false
avatarSuccess.value = ''
avatarError.value = ''
}, 800)
} catch (err: any) {
avatarError.value = err?.data?.message || 'Cập nhật avatar thất bại'
} finally {
avatarLoading.value = false
}
}
async function removeAvatar() {
avatarLoading.value = true
try {
await $fetch('/api/auth/avatar', {
method: 'PUT',
body: { avatar: '' },
})
await fetchUser()
avatarModalOpen.value = false
} catch {
avatarError.value = 'Xóa avatar thất bại'
} finally {
avatarLoading.value = false
}
}
function startEditName() {
displayNameInput.value = user.value?.name || ''
editName.value = true
nameError.value = ''
nameSuccess.value = ''
}
function cancelEditName() {
editName.value = false
displayNameInput.value = ''
nameError.value = ''
nameSuccess.value = ''
}
async function saveName() {
nameLoading.value = true
nameError.value = ''
nameSuccess.value = ''
const trimmed = displayNameInput.value.trim()
if (!trimmed) {
nameError.value = 'Tên hiển thị không được để trống'
nameLoading.value = false
return
}
if (trimmed.length > 50) {
nameError.value = 'Tên hiển thị tối đa 50 ký tự'
nameLoading.value = false
return
}
try {
await $fetch('/api/auth/profile', {
method: 'PUT',
body: { name: trimmed },
})
nameSuccess.value = 'Cập nhật tên thành công!'
await fetchUser()
setTimeout(() => {
editName.value = false
nameSuccess.value = ''
}, 1000)
} catch (err: any) {
nameError.value = err?.data?.message || 'Cập nhật thất bại'
} finally {
nameLoading.value = false
}
}
watch(user, (u) => {
if (u) {
displayNameInput.value = u.name || ''
}
}, { immediate: true })
useHead({ useHead({
title: 'Thành viên - CineK', title: 'Thành viên - CineK',
meta: [ meta: [
@@ -28,79 +212,359 @@ useHead({
</script> </script>
<template> <template>
<main class="min-h-screen bg-slate-950 text-white"> <main class="min-h-screen bg-[#0a0a0f] text-white">
<AppHeader /> <AppHeader />
<section class="mx-auto max-w-390 px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10"> <section class="mx-auto max-w-[1200px] px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pt-32 xl:px-10">
<div class="mb-6"> <div class="mb-6">
<p class="text-sm font-black uppercase text-yellow-300">Tài khoản</p> <p class="text-sm font-black uppercase text-[#FFD166]">Tài khoản</p>
<h1 class="mt-2 text-3xl font-black sm:text-4xl">Thành viên</h1> <h1 class="mt-2 text-3xl font-black sm:text-4xl">Thành viên</h1>
</div> </div>
<div v-if="user" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_22rem]"> <div v-if="user" class="flex flex-col gap-6 lg:flex-row">
<section class="rounded-lg border border-white/10 bg-white/6 p-5 sm:p-6"> <aside class="w-full lg:w-64 shrink-0">
<div class="flex flex-col gap-5 sm:flex-row sm:items-center"> <div class="lg:sticky lg:top-28">
<div class="grid size-24 shrink-0 place-items-center rounded-full bg-yellow-400/10 ring-4 ring-yellow-300/30"> <div class="mb-4 flex items-center gap-3 rounded-xl border border-white/[0.08] bg-[#12121a] p-4 lg:hidden">
<span class="text-3xl font-black text-yellow-400">{{ memberInitial }}</span> <div v-if="avatarUrl" class="size-10 shrink-0 overflow-hidden rounded-full ring-2 ring-[#FFD166]/20">
<img :src="avatarUrl" :alt="memberName" class="size-full object-cover">
</div>
<div v-else class="grid size-10 shrink-0 place-items-center rounded-full bg-[#FFD166]/10">
<span class="text-sm font-bold text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ memberName }}</p>
<p class="truncate text-xs text-white/40">{{ user.email }}</p>
</div>
</div> </div>
<div class="min-w-0">
<h2 class="truncate text-2xl font-black">{{ memberName }}</h2> <nav
<p class="mt-2 inline-flex items-center gap-2 text-sm font-semibold text-slate-300"> class="flex gap-2 overflow-x-auto pb-2 lg:flex-col lg:gap-1 lg:overflow-visible lg:rounded-xl lg:border lg:border-white/[0.08] lg:bg-[#12121a] lg:p-2">
<Mail class="size-4 text-yellow-300" /> <button v-for="item in sidebarItems" :key="item.id" type="button"
{{ user.email }} class="flex shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium transition-all lg:w-full"
</p> :class="activeTab === item.id
<p class="mt-4 max-w-2xl text-sm leading-6 text-slate-400"> ? 'bg-[#FFD166]/10 text-[#FFD166]'
Tài khoản CineK của bạn. Quản lịch sử xem các phim yêu thích. : 'text-white/50 hover:bg-white/[0.04] hover:text-white'" @click="activeTab = item.id">
</p> <component :is="item.icon" class="size-4" />
<button type="button" <span>{{ item.label }}</span>
class="mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-semibold text-red-400 transition hover:bg-red-400/20"
@click="handleLogout">
Đăng xuất
</button> </button>
</div>
<div class="my-1 hidden border-t border-white/[0.08] lg:block" />
<NuxtLink to="/"
class="hidden shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-white/40 transition hover:text-white lg:flex">
<ArrowLeft class="size-4" />
<span>Về trang chủ</span>
</NuxtLink>
<button type="button"
class="hidden shrink-0 items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-red-400/60 transition hover:bg-red-400/8 hover:text-red-400 lg:flex"
@click="handleLogout">
<LogOut class="size-4" />
<span>Đăng xuất</span>
</button>
</nav>
</div> </div>
</section>
<aside class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<NuxtLink to="/lich-su"
class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10">
<span class="grid size-10 place-items-center rounded-md bg-yellow-300 text-slate-950">
<Clock3 class="size-5" />
</span>
<span>
<span class="block font-black">Lịch sử xem</span>
<span class="text-sm text-slate-400">Xem tiếp phim đang dở</span>
</span>
</NuxtLink>
<NuxtLink to="/yeu-thich"
class="flex items-center gap-3 rounded-lg border border-white/10 bg-white/6 p-4 transition hover:bg-white/10">
<span class="grid size-10 place-items-center rounded-md bg-white/10 text-yellow-300">
<Heart class="size-5 fill-current" />
</span>
<span>
<span class="block font-black">Yêu thích</span>
<span class="text-sm text-slate-400">Phim bạn đã lưu</span>
</span>
</NuxtLink>
</aside> </aside>
</div>
<div v-else class="rounded-lg border border-white/10 bg-white/6 p-6"> <div class="min-w-0 flex-1">
<UserRound class="size-10 text-yellow-300" /> <Transition name="tab-fade" mode="out-in">
<h2 class="mt-4 text-xl font-black">Bạn chưa đăng nhập</h2> <div v-if="activeTab === 'account'" :key="'account'"
<p class="mt-2 text-sm leading-6 text-slate-300"> class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
Đăng nhập hoặc đăng tài khoản để trải nghiệm đầy đủ các tính năng trên CineK. <div class="mb-5 flex items-center gap-2.5">
</p> <Settings class="size-5 text-[#FFD166]" />
<div class="mt-4"> <h2 class="text-lg font-bold">Quản tài khoản</h2>
<button type="button" </div>
class="inline-flex h-10 items-center justify-center rounded-lg bg-yellow-300 px-5 text-sm font-black text-slate-950 transition hover:bg-white"
@click="authOpen = true"> <div class="flex flex-col gap-6 lg:flex-row">
Đăng nhập / Đăng <div class="min-w-0 flex-1 space-y-4">
</button> <div>
<label class="mb-1.5 block text-xs font-medium text-white/40">Tên hiển thị</label>
<div class="relative">
<UserRound class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="displayNameInput" type="text" placeholder="Nhập tên hiển thị"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-24 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10"
@keyup.enter="saveName">
<button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 h-8 rounded-lg bg-[#FFD166] px-3 text-xs font-bold text-[#0f111a] transition hover:bg-[#FFC845] disabled:opacity-50"
:disabled="nameLoading" @click="saveName">
{{ nameLoading ? '...' : 'Lưu' }}
</button>
</div>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-white/40">Email</label>
<div class="relative">
<Mail class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input type="text" :value="user.email" readonly
class="h-12 w-full cursor-not-allowed rounded-xl border border-white/[0.06] bg-white/[0.02] pl-11 pr-16 text-sm text-white/50 outline-none">
<span
class="absolute right-3 top-1/2 -translate-y-1/2 rounded bg-white/[0.06] px-2 py-0.5 text-[10px] font-medium text-white/30">readonly</span>
</div>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-white/40">Vai trò</label>
<input type="text" :value="user.role === 'admin' ? 'Quản trị viên' : 'Thành viên'" readonly
class="h-12 w-full cursor-not-allowed rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 text-sm text-white/50 outline-none">
</div>
<p v-if="nameError" class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
<X class="size-4 shrink-0" />
{{ nameError }}
</p>
<p v-if="nameSuccess"
class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
<Check class="size-4 shrink-0" />
{{ nameSuccess }}
</p>
<div class="flex flex-wrap gap-2 pt-2">
<button type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg bg-[#FFD166] px-4 text-sm font-bold text-[#0f111a] transition hover:bg-[#FFC845]"
@click="avatarModalOpen = true">
<Camera class="size-4" />
Đổi avatar
</button>
<button type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg bg-[#FFD166] px-4 text-sm font-bold text-[#0f111a] transition hover:bg-[#FFC845]"
@click="activeTab = 'password'">
<KeyRound class="size-4" />
Đổi mật khẩu
</button>
<button type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-4 text-sm font-semibold text-red-400 transition hover:bg-red-400/20 lg:hidden"
@click="handleLogout">
<LogOut class="size-4" />
Đăng xuất
</button>
</div>
</div>
<div class="flex flex-col items-center gap-3 lg:w-48 lg:shrink-0">
<div class="group/avatar relative">
<div v-if="avatarUrl" class="size-32 overflow-hidden rounded-full ring-4 ring-[#FFD166]/20">
<img :src="avatarUrl" :alt="memberName" class="size-full object-cover">
</div>
<div v-else
class="grid size-32 place-items-center rounded-full bg-[#FFD166]/10 ring-4 ring-[#FFD166]/20">
<span class="text-4xl font-black text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
</div>
<button type="button"
class="absolute -right-1 -bottom-1 grid size-9 place-items-center rounded-full bg-[#FFD166] text-[#0f111a] shadow-lg transition hover:bg-[#FFC845]"
@click="avatarModalOpen = true">
<Camera class="size-4" />
</button>
</div>
<p class="text-center text-sm font-semibold">{{ memberName }}</p>
<p class="text-center text-xs text-white/40">{{ user.email }}</p>
</div>
</div>
</div>
<div v-else-if="activeTab === 'password'" :key="'password'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5">
<KeyRound class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Đổi mật khẩu</h2>
</div>
<form class="max-w-md space-y-4" @submit.prevent="handleChangePassword">
<div class="relative">
<label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu hiện tại</label>
<div class="relative">
<LockKeyhole class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="currentPassword" :type="showPassword ? 'text' : 'password'" required
placeholder="Nhập mật khẩu hiện tại"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-11 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
@click="showPassword = !showPassword">
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</div>
<div class="relative">
<label class="mb-1.5 block text-xs font-medium text-white/40">Mật khẩu mới</label>
<div class="relative">
<LockKeyhole class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="newPassword" :type="showNewPassword ? 'text' : 'password'" required minlength="6"
placeholder="Ít nhất 6 ký tự"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-11 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
@click="showNewPassword = !showNewPassword">
<EyeOff v-if="showNewPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</div>
<div class="relative">
<label class="mb-1.5 block text-xs font-medium text-white/40">Xác nhận mật khẩu mới</label>
<div class="relative">
<LockKeyhole class="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/30" />
<input v-model="confirmPassword" :type="showConfirmPassword ? 'text' : 'password'" required
minlength="6" placeholder="Nhập lại mật khẩu mới"
class="h-12 w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-11 pr-11 text-sm text-white placeholder:text-white/30 outline-none transition-all focus:border-[#FFD166]/40 focus:bg-white/[0.06] focus:ring-2 focus:ring-[#FFD166]/10">
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 transition hover:text-white/60"
@click="showConfirmPassword = !showConfirmPassword">
<EyeOff v-if="showConfirmPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</div>
<button type="submit"
class="inline-flex h-12 items-center justify-center rounded-xl bg-[#FFD166] px-6 text-sm font-bold text-[#0f111a] transition-all hover:bg-[#FFC845] hover:shadow-[0_8px_25px_rgba(255,209,102,0.3)] active:scale-[0.98] disabled:opacity-50"
:disabled="passwordLoading">
{{ passwordLoading ? 'Đang xử lý...' : 'Đổi mật khẩu' }}
</button>
<p v-if="passwordError"
class="flex items-center gap-2 rounded-xl bg-red-500/10 p-3 text-sm text-red-400">
<X class="size-4 shrink-0" />
{{ passwordError }}
</p>
<p v-if="passwordSuccess"
class="flex items-center gap-2 rounded-xl bg-green-500/10 p-3 text-sm font-medium text-green-400">
<Check class="size-4 shrink-0" />
{{ passwordSuccess }}
</p>
</form>
</div>
<div v-else-if="activeTab === 'history'" :key="'history'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5">
<Clock3 class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Lịch sử xem</h2>
</div>
<NuxtLink to="/lich-su"
class="flex items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.02] p-8 text-center transition hover:border-[#FFD166]/30 hover:bg-white/[0.04]">
<div>
<Clock3 class="mx-auto size-8 text-white/20" />
<p class="mt-2 text-sm text-white/40">Xem lịch sử xem chi tiết</p>
<p class="mt-1 text-xs text-[#FFD166]">Nhấn để xem </p>
</div>
</NuxtLink>
</div>
<div v-else-if="activeTab === 'favorites'" :key="'favorites'"
class="rounded-xl border border-white/[0.08] bg-[#12121a] p-5 sm:p-6">
<div class="mb-5 flex items-center gap-2.5">
<Heart class="size-5 text-[#FFD166]" />
<h2 class="text-lg font-bold">Phim yêu thích</h2>
</div>
<NuxtLink to="/yeu-thich"
class="flex items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.02] p-8 text-center transition hover:border-[#FFD166]/30 hover:bg-white/[0.04]">
<div>
<Heart class="mx-auto size-8 text-white/20" />
<p class="mt-2 text-sm text-white/40">Xem danh sách phim yêu thích</p>
<p class="mt-1 text-xs text-[#FFD166]">Nhấn để xem </p>
</div>
</NuxtLink>
</div>
</Transition>
</div> </div>
</div> </div>
</section> </section>
<AuthModal v-model="authOpen" /> <Teleport to="body">
<Transition name="avatar-modal">
<div v-if="avatarModalOpen" class="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-[#0a0a0f]/90 backdrop-blur-md" @click="avatarModalOpen = false" />
<div
class="relative w-full max-w-lg overflow-hidden rounded-[20px] border border-white/[0.08] bg-[#12121a] shadow-[0_25px_60px_rgba(0,0,0,0.5)]">
<div class="relative h-1 bg-gradient-to-r from-[#FFD166] via-[#FFB800] to-[#FFD166]" />
<button type="button"
class="absolute right-4 top-4 z-10 grid h-8 w-8 place-items-center rounded-full bg-white/10 text-white/70 transition hover:bg-white/20 hover:text-white"
@click="avatarModalOpen = false">
<X class="size-4" />
</button>
<div class="p-5 pb-3">
<div class="mb-4 flex items-center gap-2.5">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-[#FFD166]/10">
<Camera class="size-4 text-[#FFD166]" />
</div>
<div>
<h2 class="text-lg font-bold text-white">Chọn avatar</h2>
<p class="text-xs text-white/40">Chọn hình ảnh yêu thích của bạn</p>
</div>
</div>
<div class="flex gap-1 rounded-full bg-white/[0.04] p-1">
<button v-for="cat in avatarCategories" :key="cat.id" type="button"
class="flex-1 rounded-full px-3 py-2 text-xs font-semibold transition-all" :class="selectedAvatarCategory === cat.id
? 'bg-[#FFD166] text-[#0f111a] shadow-[0_4px_12px_rgba(255,209,102,0.25)]'
: 'text-white/50 hover:text-white/80'" @click="selectedAvatarCategory = cat.id">
{{ cat.label }}
</button>
</div>
</div>
<div class="max-h-[400px] overflow-y-auto px-5 pb-5">
<div class="grid grid-cols-4 gap-2.5 sm:grid-cols-5">
<button v-for="(img, idx) in avatarGrid" :key="idx" type="button"
class="group/avatar-btn relative aspect-square overflow-hidden rounded-xl border-2 border-transparent transition-all hover:border-[#FFD166]/50 hover:shadow-[0_4px_15px_rgba(255,209,102,0.2)]"
:class="avatarUrl === img ? 'border-[#FFD166] ring-2 ring-[#FFD166]/30' : ''"
@click="selectAvatar(img)">
<img :src="img" :alt="`Avatar ${idx + 1}`"
class="size-full object-cover transition-transform group-hover/avatar-btn:scale-110">
<div v-if="avatarUrl === img"
class="absolute inset-0 flex items-center justify-center bg-[#FFD166]/20">
<div class="grid size-6 place-items-center rounded-full bg-[#FFD166]">
<Check class="size-3.5 text-[#0f111a]" />
</div>
</div>
</button>
</div>
</div>
<div class="border-t border-white/[0.06] px-5 py-3">
<div class="flex items-center justify-between">
<button v-if="avatarUrl" type="button" class="text-xs text-red-400/70 transition hover:text-red-400"
:disabled="avatarLoading" @click="removeAvatar">
Xóa avatar
</button>
<span v-else class="text-xs text-white/30">Chưa chọn avatar</span>
<p v-if="avatarSuccess" class="text-xs text-green-400">{{ avatarSuccess }}</p>
<p v-if="avatarError" class="text-xs text-red-400">{{ avatarError }}</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</main> </main>
</template> </template>
<style scoped>
.tab-fade-enter-active,
.tab-fade-leave-active {
transition: opacity 0.2s ease;
}
.tab-fade-enter-from,
.tab-fade-leave-to {
opacity: 0;
}
.avatar-modal-enter-active,
.avatar-modal-leave-active {
transition: opacity 0.2s ease;
}
.avatar-modal-enter-from,
.avatar-modal-leave-to {
opacity: 0;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+46
View File
@@ -0,0 +1,46 @@
import { users } from '../../database/schema'
import { eq } from 'drizzle-orm'
import { getTokenFromEvent } from '../../utils/auth'
const validCategories = ['meme', 'hoat-hinh', 'viet-nam']
const validNumbers: Record<string, string[]> = {
meme: ['01', '02', '03', '04', '05', '06', '07', '08'],
'hoat-hinh': ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30'],
'viet-nam': ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16'],
}
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const token = getTokenFromEvent(event)
if (!token) {
throw createError({ statusCode: 401, message: 'Chưa đăng nhập' })
}
const avatar = typeof body.avatar === 'string' ? body.avatar : ''
if (!avatar) {
await db.update(users)
.set({ avatar: null })
.where(eq(users.id, (verifyToken(token) as any).id))
return { message: 'Đã xóa avatar' }
}
const parts = avatar.split('/')
const num = parts[2]?.replace('.jpg', '')
if (parts.length !== 3 || parts[0] !== 'avatars' || !validCategories.includes(parts[1]) || !validNumbers[parts[1]]?.includes(num)) {
throw createError({ statusCode: 400, message: 'Avatar không hợp lệ' })
}
const payload = verifyToken(token)
if (!payload) {
throw createError({ statusCode: 401, message: 'Token không hợp lệ' })
}
await db.update(users)
.set({ avatar: `/${avatar}` })
.where(eq(users.id, payload.id))
return { message: 'Cập nhật avatar thành công!' }
})
+48
View File
@@ -0,0 +1,48 @@
import { users } from '../../database/schema'
import { eq } from 'drizzle-orm'
import { verifyPassword, hashPassword, getTokenFromEvent } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const token = getTokenFromEvent(event)
if (!token) {
throw createError({ statusCode: 401, message: 'Chưa đăng nhập' })
}
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
if (!currentPassword || !newPassword) {
throw createError({ statusCode: 400, message: 'Mật khẩu hiện tại và mật khẩu mới là bắt buộc' })
}
if (newPassword.length < 6) {
throw createError({ statusCode: 400, message: 'Mật khẩu mới phải có ít nhất 6 ký tự' })
}
const payload = verifyToken(token)
if (!payload) {
throw createError({ statusCode: 401, message: 'Token không hợp lệ' })
}
const result = await db.select().from(users).where(eq(users.id, payload.id)).limit(1)
if (!result.length) {
throw createError({ statusCode: 404, message: 'User không tồn tại' })
}
const user = result[0]
const valid = await verifyPassword(currentPassword, user.password)
if (!valid) {
throw createError({ statusCode: 401, message: 'Mật khẩu hiện tại không đúng' })
}
const hashedPassword = await hashPassword(newPassword)
await db.update(users)
.set({ password: hashedPassword })
.where(eq(users.id, user.id))
return { message: 'Đổi mật khẩu thành công!' }
})
+36
View File
@@ -0,0 +1,36 @@
import { users } from '../../database/schema'
import { eq } from 'drizzle-orm'
import crypto from 'node:crypto'
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
if (!email) {
throw createError({ statusCode: 400, message: 'Email là bắt buộc' })
}
const result = await db.select().from(users).where(eq(users.email, email)).limit(1)
if (!result.length) {
throw createError({ statusCode: 404, message: 'Email không tồn tại trong hệ thống' })
}
const user = result[0]
const resetToken = crypto.randomBytes(32).toString('hex')
const resetExpires = new Date(Date.now() + 60 * 60 * 1000)
await db.update(users)
.set({
resetToken,
resetTokenExpires: resetExpires,
})
.where(eq(users.id, user.id))
const resetUrl = `${process.env.APP_URL || 'http://localhost:3000'}/dat-lai-mat-khau?token=${resetToken}`
console.log(`Password reset URL for ${email}: ${resetUrl}`)
return { message: 'Đã gửi link đặt lại mật khẩu! Kiểm tra email của bạn.' }
})
+34
View File
@@ -0,0 +1,34 @@
import { users } from '../../database/schema'
import { eq } from 'drizzle-orm'
import { getTokenFromEvent } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const token = getTokenFromEvent(event)
if (!token) {
throw createError({ statusCode: 401, message: 'Chưa đăng nhập' })
}
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (!name) {
throw createError({ statusCode: 400, message: 'Tên hiển thị không được để trống' })
}
if (name.length > 50) {
throw createError({ statusCode: 400, message: 'Tên hiển thị tối đa 50 ký tự' })
}
const payload = verifyToken(token)
if (!payload) {
throw createError({ statusCode: 401, message: 'Token không hợp lệ' })
}
await db.update(users)
.set({ name })
.where(eq(users.id, payload.id))
return { message: 'Cập nhật tên thành công!' }
})
+50
View File
@@ -0,0 +1,50 @@
import { users } from '../../database/schema'
import { eq, and, gt, lt, or, isNull } from 'drizzle-orm'
import { hashPassword } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const db = useDb()
const body = await readBody(event)
const token = typeof body.token === 'string' ? body.token.trim() : ''
const password = typeof body.password === 'string' ? body.password : ''
if (!token || !password) {
throw createError({ statusCode: 400, message: 'Token và mật khẩu mới là bắt buộc' })
}
if (password.length < 6) {
throw createError({ statusCode: 400, message: 'Mật khẩu phải có ít nhất 6 ký tự' })
}
const now = new Date()
const result = await db.select()
.from(users)
.where(
and(
eq(users.resetToken, token),
or(
isNull(users.resetTokenExpires),
gt(users.resetTokenExpires, now),
),
),
)
.limit(1)
if (!result.length) {
throw createError({ statusCode: 400, message: 'Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn' })
}
const user = result[0]
const hashedPassword = await hashPassword(password)
await db.update(users)
.set({
password: hashedPassword,
resetToken: null,
resetTokenExpires: null,
})
.where(eq(users.id, user.id))
return { message: 'Đặt lại mật khẩu thành công!' }
})
@@ -0,0 +1,2 @@
ALTER TABLE `users` ADD `reset_token` varchar(255);--> statement-breakpoint
ALTER TABLE `users` ADD `reset_token_expires` timestamp;
@@ -0,0 +1,350 @@
{
"version": "5",
"dialect": "mysql",
"id": "c5e6711f-1d8a-4d55-8e5f-846bc6d44924",
"prevId": "0c944d62-4add-4bdb-b5cf-764479ba4b22",
"tables": {
"movies": {
"name": "movies",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin_name": {
"name": "origin_name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumb": {
"name": "thumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"poster": {
"name": "poster",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"year": {
"name": "year",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"episode": {
"name": "episode",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"episode_total": {
"name": "episode_total",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quality": {
"name": "quality",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lang": {
"name": "lang",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"countries": {
"name": "countries",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_poster": {
"name": "custom_poster",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_thumb": {
"name": "custom_thumb",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_content": {
"name": "custom_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_episodes": {
"name": "custom_episodes",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"api_updated_at": {
"name": "api_updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"synced_at": {
"name": "synced_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"movies_id": {
"name": "movies_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reset_token": {
"name": "reset_token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reset_token_expires": {
"name": "reset_token_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
@@ -50,6 +50,13 @@
"when": 1784780104334, "when": 1784780104334,
"tag": "0006_sour_retro_girl", "tag": "0006_sour_retro_girl",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1784816479838,
"tag": "0007_shiny_jane_foster",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -7,6 +7,8 @@ export const users = mysqlTable('users', {
password: varchar('password', { length: 255 }).notNull(), password: varchar('password', { length: 255 }).notNull(),
role: varchar('role', { length: 50 }).notNull().default('user'), role: varchar('role', { length: 50 }).notNull().default('user'),
avatar: text('avatar'), avatar: text('avatar'),
resetToken: varchar('reset_token', { length: 255 }),
resetTokenExpires: timestamp('reset_token_expires'),
active: boolean('active').notNull().default(true), active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),