feat: add gender field to user profile and update related components

This commit is contained in:
ngthanhvu
2026-07-24 04:55:39 -04:00
parent 541cb66c33
commit 6ad96873a7
11 changed files with 693 additions and 30 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CornerDownLeft, Crown, Loader2, ThumbsDown, ThumbsUp, Trash2, User } from 'lucide-vue-next'
import { CornerDownLeft, Crown, Loader2, ThumbsDown, ThumbsUp, Trash2, User, Venus, Mars, Minus } from 'lucide-vue-next'
const props = defineProps<{
replies: any[]
@@ -48,6 +48,9 @@ function handleVote(replyId: number, vote: number) {
<span v-if="reply.userRole === 'admin' && !reply.anonymous"
class="px-1.5 py-0.5 rounded text-[9px] font-black bg-yellow-400 text-slate-900">ADMIN</span>
<span class="text-xs font-semibold text-white">{{ reply.userName }}</span>
<Venus v-if="reply.userGender === 'female' && !reply.anonymous" class="size-3 text-pink-400" />
<Mars v-if="reply.userGender === 'male' && !reply.anonymous" class="size-3 text-blue-400" />
<Minus v-if="reply.userGender === 'other' && !reply.anonymous" class="size-3 text-purple-400" />
<span v-if="reply.anonymous"
class="px-1 py-0.5 rounded text-[9px] font-medium bg-white/10 text-slate-400">Ẩn
danh</span>
+6 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ChevronDown, ChevronUp, CornerDownLeft, Crown, Loader2, MessageSquare, Pin, Send, ThumbsDown, ThumbsUp, Trash2, User } from 'lucide-vue-next'
import { ChevronDown, ChevronUp, CornerDownLeft, Crown, Loader2, MessageSquare, Pin, Send, ThumbsDown, ThumbsUp, Trash2, User, Venus, Mars, Minus } from 'lucide-vue-next'
const props = defineProps<{
source: string
@@ -110,6 +110,7 @@ async function handleSubmitComment() {
userName: user.value.name || 'Ẩn danh',
userAvatar: user.value.avatar,
userRole: user.value.role,
userGender: user.value.gender,
replies: [],
timeAgo: 'Vừa xong'
})
@@ -229,6 +230,7 @@ async function submitReply(parentId: number) {
userName: user.value.name || 'Ẩn danh',
userAvatar: user.value.avatar,
userRole: user.value.role,
userGender: user.value.gender,
timeAgo: 'Vừa xong',
anonymous: isAnon,
replies: []
@@ -423,6 +425,9 @@ watch(() => props.slug, () => {
<span v-if="comment.userRole === 'admin' && !comment.anonymous"
class="px-2 py-0.5 rounded text-[10px] font-black bg-yellow-400 text-slate-900 shadow-sm">ADMIN</span>
<span class="text-sm font-semibold text-white">{{ comment.userName }}</span>
<Venus v-if="comment.userGender === 'female' && !comment.anonymous" class="size-3.5 text-pink-400" />
<Mars v-if="comment.userGender === 'male' && !comment.anonymous" class="size-3.5 text-blue-400" />
<Minus v-if="comment.userGender === 'other' && !comment.anonymous" class="size-3.5 text-purple-400" />
<span v-if="comment.anonymous"
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-white/10 text-slate-400">Ẩn danh</span>
<span class="text-xs text-slate-600">{{ timeAgo(comment.createdAt) }}</span>
+7 -4
View File
@@ -1,11 +1,14 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path !== '/thanh-vien') return
const { data: user, error } = await useFetch('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
let user = null
try {
user = await $fetch('/api/auth/me')
} catch {
return navigateTo('/')
}
if (error.value?.statusCode === 401 || !user.value) {
if (!user) {
return navigateTo('/')
}
})
+76 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowLeft, Camera, Check, Clock3, Eye, EyeOff, Heart, KeyRound, LogOut, LockKeyhole, Mail, Pencil, Settings, UserRound, X } from 'lucide-vue-next'
import { ArrowLeft, Camera, Check, Clock3, Crown, Eye, EyeOff, Heart, KeyRound, LogOut, LockKeyhole, Mail, Pencil, Settings, UserRound, X, Venus, Mars, Minus } from 'lucide-vue-next'
definePageMeta({ middleware: 'auth' })
@@ -25,6 +25,15 @@ const displayNameInput = ref('')
const nameLoading = ref(false)
const nameError = ref('')
const nameSuccess = ref('')
const genderLoading = ref(false)
const genderError = ref('')
const genderSuccess = ref('')
const genderOptions = [
{ value: 'male', label: 'Nam', icon: Mars },
{ value: 'female', label: 'Nữ', icon: Venus },
{ value: 'other', label: 'Khác', icon: Minus },
]
const avatarCategories = [
{ id: 'meme', label: 'Meme' },
@@ -43,6 +52,8 @@ const avatarUrl = computed(() => {
return null
})
const isAdmin = computed(() => user.value?.role === 'admin')
const avatarGrid = computed(() => {
const categoryMap: Record<string, string[]> = {
meme: ['01', '02', '03', '04', '05', '06', '07', '08'],
@@ -194,6 +205,28 @@ async function saveName() {
}
}
async function saveGender(gender: string) {
genderLoading.value = true
genderError.value = ''
genderSuccess.value = ''
try {
await $fetch('/api/auth/profile', {
method: 'PUT',
body: { gender },
})
genderSuccess.value = 'Cập nhật giới tính thành công!'
await fetchUser()
setTimeout(() => {
genderSuccess.value = ''
}, 1500)
} catch (err: any) {
genderError.value = err?.data?.message || 'Cập nhật thất bại'
} finally {
genderLoading.value = false
}
}
watch(user, (u) => {
if (u) {
displayNameInput.value = u.name || ''
@@ -225,11 +258,17 @@ useHead({
<aside class="w-full lg:w-64 shrink-0">
<div class="lg:sticky lg:top-28">
<div class="mb-4 flex items-center gap-3 rounded-xl border border-white/[0.08] bg-[#12121a] p-4 lg:hidden">
<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 class="relative shrink-0">
<div v-if="avatarUrl" class="size-10 shrink-0 overflow-hidden rounded-full"
:class="isAdmin ? 'ring-2 ring-[#FFD166]' : '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"
:class="isAdmin ? 'bg-[#FFD166]/20 ring-2 ring-[#FFD166]' : 'bg-[#FFD166]/10 ring-2 ring-[#FFD166]/20'">
<span class="text-sm font-bold text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
</div>
<Crown v-if="isAdmin"
class="absolute -right-1 -top-1 grid size-5 place-items-center rounded-full bg-[#0a0a0f] p-0.5 text-[#FFD166] ring-1 ring-[#FFD166]/40" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ memberName }}</p>
@@ -309,6 +348,21 @@ useHead({
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>
<div>
<label class="mb-2 block text-xs font-medium text-white/40">Giới tính</label>
<div class="flex gap-2">
<button v-for="opt in genderOptions" :key="opt.value" type="button"
class="flex-1 flex items-center justify-center gap-2 h-12 rounded-xl border text-sm font-semibold transition-all"
:class="user.gender === opt.value
? 'border-[#FFD166]/40 bg-[#FFD166]/10 text-[#FFD166]'
: 'border-white/[0.08] bg-white/[0.04] text-white/50 hover:border-white/[0.15] hover:text-white/70'"
:disabled="genderLoading" @click="saveGender(opt.value)">
<component :is="opt.icon" class="size-4" />
<span>{{ opt.label }}</span>
</button>
</div>
</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 }}
@@ -318,6 +372,16 @@ useHead({
<Check class="size-4 shrink-0" />
{{ nameSuccess }}
</p>
<p v-if="genderError"
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" />
{{ genderError }}
</p>
<p v-if="genderSuccess"
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" />
{{ genderSuccess }}
</p>
<div class="flex flex-wrap gap-2 pt-2">
<button type="button"
@@ -343,13 +407,16 @@ useHead({
<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">
<div v-if="avatarUrl" class="size-32 overflow-hidden rounded-full"
:class="isAdmin ? 'ring-4 ring-[#FFD166]' : '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">
<div v-else class="grid size-32 place-items-center rounded-full"
:class="isAdmin ? 'bg-[#FFD166]/20 ring-4 ring-[#FFD166]' : 'bg-[#FFD166]/10 ring-4 ring-[#FFD166]/20'">
<span class="text-4xl font-black text-[#FFD166]">{{ memberName.charAt(0).toUpperCase() }}</span>
</div>
<Crown v-if="isAdmin"
class="absolute -right-2 -top-2 grid size-9 place-items-center rounded-full bg-[#FFD166] p-1.5 text-[#0f111a] ring-2 ring-[#FFD166]/40" />
<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">
+1
View File
@@ -28,5 +28,6 @@ export default defineEventHandler(async (event) => {
name: user.name,
role: user.role,
avatar: user.avatar,
gender: user.gender,
}
})
+21 -15
View File
@@ -1,6 +1,6 @@
import { users } from '../../database/schema'
import { eq } from 'drizzle-orm'
import { getTokenFromEvent } from '../../utils/auth'
import { getTokenFromEvent, verifyToken } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const db = useDb()
@@ -11,24 +11,30 @@ export default defineEventHandler(async (event) => {
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))
const updates: Record<string, any> = {}
return { message: 'Cập nhật tên thành công!' }
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name) {
if (name.length > 50) {
throw createError({ statusCode: 400, message: 'Tên hiển thị tối đa 50 ký tự' })
}
updates.name = name
}
if (['male', 'female', 'other', ''].includes(body.gender)) {
updates.gender = body.gender || null
}
if (!Object.keys(updates).length) {
throw createError({ statusCode: 400, message: 'Không có dữ liệu cập nhật' })
}
await db.update(users).set(updates).where(eq(users.id, payload.id))
return { message: 'Cập nhật thành công!' }
})
+3
View File
@@ -36,6 +36,7 @@ export default defineEventHandler(async (event) => {
userName: users.name,
userAvatar: users.avatar,
userRole: users.role,
userGender: users.gender,
source: comments.source,
slug: comments.slug,
movieName: comments.movieName,
@@ -75,6 +76,7 @@ export default defineEventHandler(async (event) => {
userName: rep.anonymous ? 'Ẩn danh' : (rep.userName || 'Ẩn danh'),
userAvatar: rep.anonymous ? null : rep.userAvatar,
userRole: rep.userRole,
userGender: rep.userGender,
parentId: rep.parentId,
content: rep.content,
pinned: rep.pinned || false,
@@ -97,6 +99,7 @@ export default defineEventHandler(async (event) => {
userName: users.name,
userAvatar: users.avatar,
userRole: users.role,
userGender: users.gender,
parentId: comments.parentId,
content: comments.content,
pinned: comments.pinned,
@@ -0,0 +1 @@
ALTER TABLE `users` ADD `gender` varchar(20);
@@ -0,0 +1,566 @@
{
"version": "5",
"dialect": "mysql",
"id": "cdff5cf9-c17b-4367-a3db-46bd089b4b44",
"prevId": "80c783b0-5942-4747-b8da-78c937b01d64",
"tables": {
"comment_votes": {
"name": "comment_votes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment_id": {
"name": "comment_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"vote": {
"name": "vote",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"idx_user_comment": {
"name": "idx_user_comment",
"columns": [
"user_id",
"comment_id"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"comment_votes_id": {
"name": "comment_votes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"comments": {
"name": "comments",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"slug": {
"name": "slug",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"movie_name": {
"name": "movie_name",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pinned": {
"name": "pinned",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"spoiler": {
"name": "spoiler",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"anonymous": {
"name": "anonymous",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"like_count": {
"name": "like_count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"dislike_count": {
"name": "dislike_count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"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": {
"idx_source_slug": {
"name": "idx_source_slug",
"columns": [
"source",
"slug"
],
"isUnique": false
},
"idx_user_id": {
"name": "idx_user_id",
"columns": [
"user_id"
],
"isUnique": false
},
"idx_parent_id": {
"name": "idx_parent_id",
"columns": [
"parent_id"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"comments_id": {
"name": "comments_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"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
},
"gender": {
"name": "gender",
"type": "varchar(20)",
"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": {}
}
}
@@ -85,6 +85,13 @@
"when": 1784853783915,
"tag": "0011_magenta_jack_flag",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1784882595519,
"tag": "0012_bumpy_the_watchers",
"breakpoints": true
}
]
}
+1
View File
@@ -7,6 +7,7 @@ export const users = mysqlTable('users', {
password: varchar('password', { length: 255 }).notNull(),
role: varchar('role', { length: 50 }).notNull().default('user'),
avatar: text('avatar'),
gender: varchar('gender', { length: 20 }),
resetToken: varchar('reset_token', { length: 255 }),
resetTokenExpires: timestamp('reset_token_expires'),
active: boolean('active').notNull().default(true),