451 lines
21 KiB
Vue
451 lines
21 KiB
Vue
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'default',
|
|
middleware: 'auth'
|
|
})
|
|
|
|
const { locale, t } = useI18n()
|
|
const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail, fetchUserProfile } = useAuth()
|
|
const { getLocalizedText } = useCourse()
|
|
import { useQuasar } from 'quasar'
|
|
const $q = useQuasar()
|
|
|
|
useHead({
|
|
title: `${t('userMenu.settings')} - e-Learning`
|
|
})
|
|
|
|
|
|
|
|
const isEditing = ref(false)
|
|
const activeTab = ref<'general' | 'security'>('general')
|
|
const isProfileSaving = ref(false)
|
|
const isPasswordSaving = ref(false)
|
|
const isSendingVerify = ref(false)
|
|
const isHydrated = ref(false)
|
|
|
|
|
|
|
|
const formatDate = (dateString?: string) => {
|
|
if (!dateString) return '-'
|
|
try {
|
|
const date = new Date(dateString)
|
|
return new Intl.DateTimeFormat(locale.value === 'th' ? 'th-TH' : 'en-US', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
}).format(date)
|
|
} catch (e) {
|
|
return dateString
|
|
}
|
|
}
|
|
|
|
const userData = ref({
|
|
firstName: currentUser.value?.firstName || '',
|
|
lastName: currentUser.value?.lastName || '',
|
|
email: currentUser.value?.email || '',
|
|
phone: currentUser.value?.phone || '',
|
|
createdAt: currentUser.value?.createdAt || '',
|
|
photoURL: currentUser.value?.photoURL || '',
|
|
prefix: currentUser.value?.prefix?.th || '',
|
|
emailVerifiedAt: currentUser.value?.emailVerifiedAt
|
|
})
|
|
|
|
// ...
|
|
|
|
|
|
const passwordForm = reactive({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: ''
|
|
})
|
|
|
|
const showPasswordModal = ref(false)
|
|
const showPassword = reactive({
|
|
current: false,
|
|
new: false,
|
|
confirm: false
|
|
})
|
|
|
|
|
|
// กฎต่างๆ ถูกย้ายไปที่คอมโพเนนต์แล้ว (Rules have been moved to components)
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null) // ใช้ในโหมดมุมมอง (อยู่นอกคอมโพเนนต์) (Used in view mode (outside component))
|
|
|
|
const toggleEdit = (edit: boolean) => {
|
|
isEditing.value = edit
|
|
}
|
|
|
|
// อัปเดตให้รับออบเจ็กต์ File ได้โดยตรง (หรือ Event สำหรับความเข้ากันได้ของโหมดมุมมองหากจำเป็น) (Updated to accept File object directly (or Event for view mode compatibility if needed))
|
|
const handleFileUpload = async (fileOrEvent: File | Event) => {
|
|
let file: File | null = null
|
|
|
|
if (fileOrEvent instanceof File) {
|
|
file = fileOrEvent
|
|
} else {
|
|
// การทำงานสำรองสำหรับอีเวนต์ input change แบบเนทีฟ (Fallback for native input change event)
|
|
const target = (fileOrEvent as Event).target as HTMLInputElement
|
|
if (target.files && target.files[0]) {
|
|
file = target.files[0]
|
|
}
|
|
}
|
|
|
|
if (file) {
|
|
// แสดงรูป Preview ก่อนอัปโหลด
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
userData.value.photoURL = e.target?.result as string
|
|
}
|
|
reader.readAsDataURL(file)
|
|
|
|
// เรียกฟังก์ชัน uploadAvatar จาก useAuth
|
|
isProfileSaving.value = true
|
|
const result = await uploadAvatar(file)
|
|
isProfileSaving.value = false
|
|
|
|
if (result.success && result.data?.avatar_url) {
|
|
userData.value.photoURL = result.data.avatar_url
|
|
$q.notify({ type: 'positive', message: 'อัปเดตรูปโปรไฟล์สำเร็จ', position: 'top' })
|
|
} else {
|
|
console.error('Upload failed:', result.error)
|
|
$q.notify({ type: 'negative', message: result.error || t('profile.updateError') || 'อัปเดตรูปโปรไฟล์ไม่สำเร็จ', position: 'top' })
|
|
}
|
|
}
|
|
}
|
|
|
|
// เรียกการอัปโหลดเมื่อคลิกที่รูปโปรไฟล์ในโหมด View (Trigger upload for VIEW mode avatar click)
|
|
const triggerUpload = () => {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
|
|
|
|
const handleUpdateProfile = async () => {
|
|
isProfileSaving.value = true
|
|
|
|
const prefixMap: Record<string, string> = {
|
|
'นาย': 'Mr.',
|
|
'นาง': 'Mrs.',
|
|
'นางสาว': 'Ms.'
|
|
}
|
|
|
|
const payload = {
|
|
first_name: userData.value.firstName,
|
|
last_name: userData.value.lastName,
|
|
phone: userData.value.phone,
|
|
prefix: {
|
|
th: userData.value.prefix,
|
|
en: prefixMap[userData.value.prefix] || ''
|
|
}
|
|
}
|
|
|
|
const result = await updateUserProfile(payload)
|
|
|
|
if (result?.success) {
|
|
$q.notify({ type: 'positive', message: t('profile.updateSuccess'), position: 'top' })
|
|
} else {
|
|
$q.notify({ type: 'negative', message: result?.error || t('profile.updateError'), position: 'top' })
|
|
}
|
|
|
|
isProfileSaving.value = false
|
|
}
|
|
|
|
const handleSendVerifyEmail = async () => {
|
|
isSendingVerify.value = true
|
|
const result = await sendVerifyEmail()
|
|
isSendingVerify.value = false
|
|
|
|
if (result.success) {
|
|
$q.notify({ type: 'positive', message: result.message || t('profile.verifyEmailSuccess') || 'ส่งอีเมลยืนยันสำเร็จ', position: 'top' })
|
|
} else {
|
|
if (result.code === 400) {
|
|
$q.notify({ type: 'warning', message: t('profile.emailAlreadyVerified') || 'อีเมลของคุณได้รับการยืนยันแล้ว', position: 'top' })
|
|
} else {
|
|
$q.notify({ type: 'negative', message: result.error || t('profile.verifyEmailError') || 'ส่งอีเมลไม่สำเร็จ', position: 'top' })
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleUpdatePassword = async () => {
|
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
|
$q.notify({ type: 'negative', message: 'รหัสผ่านใหม่ไม่ตรงกัน', position: 'top' })
|
|
return
|
|
}
|
|
|
|
isPasswordSaving.value = true
|
|
|
|
const result = await changePassword({
|
|
oldPassword: passwordForm.currentPassword,
|
|
newPassword: passwordForm.newPassword
|
|
})
|
|
|
|
if (result.success) {
|
|
$q.notify({ type: 'positive', message: t('profile.passwordSuccess') || 'เปลี่ยนรหัสผ่านสำเร็จ', position: 'top' })
|
|
passwordForm.currentPassword = ''
|
|
passwordForm.newPassword = ''
|
|
passwordForm.confirmPassword = ''
|
|
showPasswordModal.value = false
|
|
} else {
|
|
$q.notify({ type: 'negative', message: result.error || t('profile.passwordError') || 'เปลี่ยนรหัสผ่านไม่สำเร็จ', position: 'top' })
|
|
}
|
|
|
|
isPasswordSaving.value = false
|
|
}
|
|
|
|
// เฝ้าดูการเปลี่ยนแปลงในสถานะผู้ใช้ส่วนกลาง (เช่น หลังจากอัปโหลดรูปโปรไฟล์หรืออัปเดตข้อมูลส่วนตัว) (Watch for changes in global user state (e.g. after avatar upload or profile update))
|
|
watch(() => currentUser.value, (newUser) => {
|
|
if (newUser) {
|
|
userData.value.photoURL = newUser.photoURL || ''
|
|
userData.value.firstName = newUser.firstName
|
|
userData.value.lastName = newUser.lastName
|
|
userData.value.prefix = newUser.prefix?.th || ''
|
|
userData.value.email = newUser.email
|
|
userData.value.phone = newUser.phone || ''
|
|
userData.value.createdAt = newUser.createdAt || ''
|
|
userData.value.emailVerifiedAt = newUser.emailVerifiedAt
|
|
}
|
|
}, { deep: true })
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
await fetchUserProfile(true)
|
|
isHydrated.value = true
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-container bg-[#F8F9FA] dark:bg-[#020617] transition-colors duration-300 min-h-screen">
|
|
|
|
<div v-if="!isHydrated" class="flex justify-center py-20">
|
|
<q-spinner size="3rem" color="primary" />
|
|
</div>
|
|
|
|
<!-- การตั้งค่าโปรไฟล์หลัก (MAIN PROFILE SETTINGS) -->
|
|
<div v-else class="max-w-5xl mx-auto pb-20 fade-in pt-4">
|
|
|
|
<!-- บัตรข้อมูลโปรไฟล์ (Profile Card) -->
|
|
<div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm mb-6 overflow-hidden">
|
|
<div class="p-8 border-b border-slate-200 dark:border-slate-800">
|
|
<h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.myProfile') }}</h2>
|
|
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">{{ $t('profile.publicInfo') }}</p>
|
|
</div>
|
|
|
|
<div class="p-8">
|
|
<!-- ส่วนอัปโหลดรูปโปรไฟล์ -->
|
|
<div class="flex flex-col sm:flex-row items-center sm:items-center gap-6 mb-10 text-center sm:text-left">
|
|
<div class="relative cursor-pointer" @click="triggerUpload">
|
|
<UserAvatar
|
|
:photo-u-r-l="userData.photoURL"
|
|
:first-name="userData.firstName"
|
|
:last-name="userData.lastName"
|
|
size="100"
|
|
class="rounded-full bg-slate-100 dark:bg-slate-800 object-cover border-4 border-slate-50 dark:border-slate-800"
|
|
/>
|
|
<div class="absolute bottom-0 right-0 bg-[#3B6BE8] text-white p-1.5 rounded-full border-2 border-white dark:border-slate-900 hover:bg-blue-700 transition flex items-center justify-center">
|
|
<q-icon name="edit" size="14px" />
|
|
</div>
|
|
<input type="file" ref="fileInput" class="hidden" accept="image/jpeg, image/png, image/gif" @change="handleFileUpload" />
|
|
</div>
|
|
<div class="flex flex-col items-center sm:items-start">
|
|
<button @click="triggerUpload" class="bg-[#3B6BE8] hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition mb-2 shadow-sm whitespace-nowrap">
|
|
<span v-if="isProfileSaving"><q-spinner size="18px" /> {{ $t('profile.uploading') }}</span>
|
|
<span v-else>{{ $t('profile.changeAvatar') }}</span>
|
|
</button>
|
|
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1">{{ $t('profile.avatarHint') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ฟิลด์ข้อมูลฟอร์ม (แบ่งเป็น 2 คอลัมน์) (Form Inputs (2 Column Grid)) -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-4">
|
|
<div class="md:col-span-2 relative">
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.prefix') }}</label>
|
|
<select v-model="userData.prefix" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white appearance-none cursor-pointer">
|
|
<option value="" disabled>{{ $t('profile.selectPrefix') }}</option>
|
|
<option value="นาย">{{ $t('profile.mr') }}</option>
|
|
<option value="นาง">{{ $t('profile.mrs') }}</option>
|
|
<option value="นางสาว">{{ $t('profile.miss') }}</option>
|
|
</select>
|
|
<div class="pointer-events-none absolute bottom-0 right-0 flex items-center px-4 h-11 text-slate-500">
|
|
<q-icon name="arrow_drop_down" size="24px" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.firstName') }}-{{ $t('profile.lastName') }}</label>
|
|
<div class="flex gap-3">
|
|
<input type="text" v-model="userData.firstName" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" :placeholder="$t('profile.firstName')" />
|
|
<input type="text" v-model="userData.lastName" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" :placeholder="$t('profile.lastName')" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300">{{ $t('profile.email') }}</label>
|
|
<div v-if="userData.emailVerifiedAt" class="flex items-center gap-1 text-green-500 text-xs font-bold">
|
|
<q-icon name="verified_user" size="14px" /> {{ $t('profile.emailVerified') }}
|
|
</div>
|
|
<button v-else @click="handleSendVerifyEmail" :disabled="isSendingVerify" class="flex items-center gap-1 text-amber-500 hover:text-amber-600 text-xs font-bold transition">
|
|
<q-icon name="warning" size="14px" /> <span v-if="isSendingVerify"><q-spinner size="xs" /> {{ $t('profile.verifying') }}</span><span v-else class="underline">{{ $t('profile.verifyNow') }}</span>
|
|
</button>
|
|
</div>
|
|
<input type="email" v-model="userData.email" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none transition text-sm font-medium text-slate-500 dark:text-slate-400" disabled />
|
|
</div>
|
|
<div>
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.phone') }}</label>
|
|
<input type="tel" v-model="userData.phone" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" placeholder="08x-xxx-xxxx" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.joinedAt') }}</label>
|
|
<input type="text" :value="formatDate(userData.createdAt)" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none transition text-sm font-medium text-slate-500 dark:text-slate-400" disabled />
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- ปุ่มกดยืนยันต่างๆ (Footer Buttons) -->
|
|
<div class="px-6 sm:px-8 py-5 border-t border-slate-200 dark:border-slate-800 flex flex-col sm:flex-row justify-center sm:justify-end gap-3 items-center bg-white dark:!bg-slate-900">
|
|
<button class="w-full sm:w-auto text-[13px] font-bold text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 transition order-2 sm:order-1" @click="fetchUserProfile(true)">{{ $t('common.cancel') }}</button>
|
|
<button @click="handleUpdateProfile" :disabled="isProfileSaving" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-[13px] font-bold transition shadow-sm disabled:opacity-50 order-1 sm:order-2">
|
|
<span v-if="isProfileSaving"><q-spinner size="18px" color="white" class="mr-1" /> {{ $t('profile.saving') }}</span>
|
|
<span v-else>{{ $t('common.saveChanges') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- การ์ดความปลอดภัย (Security Card) -->
|
|
<div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm overflow-hidden">
|
|
<div class="p-8 border-b border-slate-200 dark:border-slate-800">
|
|
<h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.security') }}</h2>
|
|
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">{{ $t('profile.securitySubtitle') }}</p>
|
|
</div>
|
|
|
|
<div class="px-6 sm:px-8 py-8 sm:py-10">
|
|
<div class="p-5 sm:p-6 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 flex flex-col sm:flex-row items-center sm:items-center justify-between gap-6 text-center sm:text-left">
|
|
<div class="flex flex-col sm:flex-row items-center gap-4">
|
|
<div class="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center shrink-0">
|
|
<q-icon name="key" size="24px" />
|
|
</div>
|
|
<div>
|
|
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] sm:text-[16px]">{{ $t('profile.password') }}</h3>
|
|
<p class="text-slate-500 dark:text-slate-400 text-xs mt-0.5 sm:mt-1">{{ $t('profile.securitySubtitle') }}</p>
|
|
</div>
|
|
</div>
|
|
<button @click="showPasswordModal = true" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-sm font-bold transition shadow-sm shadow-blue-500/10">
|
|
{{ $t('profile.changePasswordBtn') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- โมดอลเปลี่ยนรหัสผ่าน (Password Modal) -->
|
|
<q-dialog v-model="showPasswordModal">
|
|
<q-card class="w-full max-w-md rounded-2xl p-2 dark:bg-slate-900 shadow-xl">
|
|
<q-form @submit="handleUpdatePassword">
|
|
<q-card-section class="flex items-center justify-between pb-2">
|
|
<div class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.changePasswordBtn') }}</div>
|
|
<q-btn icon="close" flat round dense v-close-popup class="text-slate-500" />
|
|
</q-card-section>
|
|
|
|
<q-card-section class="pt-2">
|
|
<div class="space-y-1">
|
|
<div>
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.currentPassword') }}</label>
|
|
<q-input
|
|
v-model="passwordForm.currentPassword"
|
|
:type="showPassword.current ? 'text' : 'password'"
|
|
outlined
|
|
dense
|
|
class="custom-pwd-input"
|
|
:rules="[val => !!val || $t('common.required')]"
|
|
>
|
|
<template v-slot:append>
|
|
<q-icon
|
|
:name="showPassword.current ? 'visibility_off' : 'visibility'"
|
|
class="cursor-pointer text-slate-400"
|
|
@click="showPassword.current = !showPassword.current"
|
|
/>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.newPassword') }}</label>
|
|
<q-input
|
|
v-model="passwordForm.newPassword"
|
|
:type="showPassword.new ? 'text' : 'password'"
|
|
outlined
|
|
dense
|
|
class="custom-pwd-input"
|
|
:rules="[
|
|
val => !!val || $t('common.required'),
|
|
val => val.length >= 6 || $t('profile.newPasswordHint')
|
|
]"
|
|
>
|
|
<template v-slot:append>
|
|
<q-icon
|
|
:name="showPassword.new ? 'visibility_off' : 'visibility'"
|
|
class="cursor-pointer text-slate-400"
|
|
@click="showPassword.new = !showPassword.new"
|
|
/>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.confirmNewPassword') }}</label>
|
|
<q-input
|
|
v-model="passwordForm.confirmPassword"
|
|
:type="showPassword.confirm ? 'text' : 'password'"
|
|
outlined
|
|
dense
|
|
class="custom-pwd-input"
|
|
:rules="[
|
|
val => !!val || $t('common.required'),
|
|
val => val === passwordForm.newPassword || $t('common.passwordsDoNotMatch')
|
|
]"
|
|
>
|
|
<template v-slot:append>
|
|
<q-icon
|
|
:name="showPassword.confirm ? 'visibility_off' : 'visibility'"
|
|
class="cursor-pointer text-slate-400"
|
|
@click="showPassword.confirm = !showPassword.confirm"
|
|
/>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right" class="pt-2 pb-2 px-4">
|
|
<q-btn flat :label="$t('common.cancel')" color="grey-7" v-close-popup class="font-bold text-[13px]" />
|
|
<q-btn type="submit" unelevated color="primary" :label="$t('common.save')" :loading="isPasswordSaving" class="font-bold rounded-lg px-4 text-[13px]" />
|
|
</q-card-actions>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.text-main {
|
|
color: white;
|
|
}
|
|
|
|
.custom-pwd-input :deep(.q-field__control) {
|
|
border-radius: 8px;
|
|
background-color: #f8fafc;
|
|
}
|
|
.dark .custom-pwd-input :deep(.q-field__control) {
|
|
background-color: #1e293b;
|
|
}
|
|
|
|
.fade-in {
|
|
animation: fadeIn 0.4s ease-out forwards;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|