200 lines
8.8 KiB
TypeScript
200 lines
8.8 KiB
TypeScript
import { useRef, useState, type ChangeEvent } from 'react';
|
|
import { Building2, Camera, Loader2, Mail, ShieldCheck, Trash2, type LucideIcon } from 'lucide-react';
|
|
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
|
import { PageHeader } from '../../components/PageHeader';
|
|
import { useToast } from '../../components/ToastProvider';
|
|
import { useProfileAvatar } from '../../hooks/useProfileAvatar';
|
|
import { deleteProfileAvatar, notifyProfileAvatarDeleted, notifyProfileAvatarUpdated, resizeImageToWebp, uploadProfileAvatar } from '../../lib/profileAvatar';
|
|
import type { AuthSession } from '../../types/auth';
|
|
|
|
type ProfilePageProps = {
|
|
session: AuthSession;
|
|
};
|
|
|
|
export function ProfilePage({ session }: ProfilePageProps) {
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const { showToast } = useToast();
|
|
const { avatarObjectUrl, avatarMissing, avatarLoading, refreshAvatar, setAvatarMissing } = useProfileAvatar(session.accessToken);
|
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
|
const [avatarDeleting, setAvatarDeleting] = useState(false);
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const displayName = getDisplayName(session);
|
|
const affiliation = getAffiliation(session);
|
|
const roleLabel = getRoleLabel(session.user.role);
|
|
const organizationName = session.user.primaryAffiliation?.organizationName ?? '소속 미지정';
|
|
const department = session.user.primaryAffiliation?.department ?? '부서 미지정';
|
|
const position = session.user.primaryAffiliation?.position ?? '직책 미지정';
|
|
const hasAvatar = !avatarMissing && Boolean(avatarObjectUrl);
|
|
const avatarBusy = avatarLoading || avatarUploading || avatarDeleting;
|
|
|
|
const handleAvatarSelect = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
|
|
setAvatarUploading(true);
|
|
|
|
try {
|
|
const imageBlob = await resizeImageToWebp(file, 128);
|
|
await uploadProfileAvatar(session.accessToken, imageBlob);
|
|
refreshAvatar();
|
|
notifyProfileAvatarUpdated();
|
|
showToast({ title: '프로필 이미지가 변경되었습니다.', variant: 'success' });
|
|
} catch (error) {
|
|
showToast({
|
|
title: '프로필 이미지 변경 실패',
|
|
message: error instanceof Error ? error.message : '이미지를 다시 확인해주세요.',
|
|
variant: 'error',
|
|
});
|
|
} finally {
|
|
setAvatarUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleAvatarDelete = async () => {
|
|
setAvatarDeleting(true);
|
|
|
|
try {
|
|
await deleteProfileAvatar(session.accessToken);
|
|
setAvatarMissing(true);
|
|
notifyProfileAvatarDeleted();
|
|
setDeleteConfirmOpen(false);
|
|
showToast({ title: '프로필 이미지가 삭제되었습니다.', variant: 'success' });
|
|
} catch {
|
|
showToast({ title: '프로필 이미지 삭제 실패', message: '잠시 후 다시 시도해주세요.', variant: 'error' });
|
|
} finally {
|
|
setAvatarDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-[1220px]">
|
|
<PageHeader route="profile" />
|
|
|
|
<section className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
|
<div className="rounded-lg border border-[#c7d2e0] bg-white p-5">
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative h-16 w-16 shrink-0">
|
|
<div className="grid h-full w-full place-items-center overflow-hidden rounded-full border border-[#dbe3ef] bg-[#2563eb] text-[22px] font-black text-white shadow-[0_12px_24px_rgba(37,99,235,0.18)]">
|
|
{hasAvatar ? (
|
|
<img
|
|
src={avatarObjectUrl ?? undefined}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
onError={() => setAvatarMissing(true)}
|
|
/>
|
|
) : (
|
|
displayName.slice(0, 1)
|
|
)}
|
|
</div>
|
|
{avatarBusy ? (
|
|
<span className="absolute inset-0 grid place-items-center rounded-full bg-white/65 text-[#2563eb] backdrop-blur-[2px]">
|
|
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
|
|
</span>
|
|
) : null}
|
|
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarSelect} />
|
|
<button
|
|
type="button"
|
|
className="absolute bottom-0 right-0 z-10 grid h-7 w-7 translate-x-0.5 translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#2563eb] text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={avatarBusy}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
aria-label="프로필 이미지 등록"
|
|
>
|
|
<Camera size={14} aria-hidden="true" />
|
|
</button>
|
|
{hasAvatar ? (
|
|
<button
|
|
type="button"
|
|
className="absolute right-0 top-0 z-10 grid h-7 w-7 translate-x-0.5 -translate-y-0.5 cursor-pointer place-items-center rounded-full border border-white bg-[#fff1f2] text-[#e11d48] shadow-[0_8px_18px_rgba(225,29,72,0.18)] transition hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={avatarBusy}
|
|
onClick={() => setDeleteConfirmOpen(true)}
|
|
aria-label="프로필 이미지 삭제"
|
|
>
|
|
<Trash2 size={13} aria-hidden="true" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-[18px] font-black text-[#111827]">{displayName}</h2>
|
|
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{roleLabel}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-3">
|
|
<ProfileRow icon={Building2} label="소속" value={affiliation} />
|
|
<ProfileRow icon={Mail} label="이메일" value={session.user.email} />
|
|
<ProfileRow icon={ShieldCheck} label="권한" value={roleLabel} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-[#c7d2e0] bg-white">
|
|
<div className="border-b border-[#cbd5e1] px-5 py-4">
|
|
<h2 className="text-[15px] font-black text-[#111827]">계정 정보</h2>
|
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">공통 인증 서버에서 전달된 로그인 정보입니다.</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 p-5 md:grid-cols-2">
|
|
<InfoCard label="소속 기관" value={organizationName} />
|
|
<InfoCard label="부서" value={department} />
|
|
<InfoCard label="직책" value={position} />
|
|
<InfoCard label="계정 상태" value="활성" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<ConfirmDialog
|
|
open={deleteConfirmOpen}
|
|
title="프로필 이미지 삭제"
|
|
message="등록된 썸네일 이미지를 삭제하시겠습니까?"
|
|
confirmLabel="삭제"
|
|
loading={avatarDeleting}
|
|
danger
|
|
icon={Trash2}
|
|
onCancel={() => setDeleteConfirmOpen(false)}
|
|
onConfirm={() => void handleAvatarDelete()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-lg bg-[#f8fafc] px-3 py-3">
|
|
<Icon className="mt-0.5 shrink-0 text-[#64748b]" size={16} aria-hidden="true" />
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-bold text-[#94a3b8]">{label}</p>
|
|
<p className="mt-1 break-words text-[13px] font-bold text-[#111827]">{value}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoCard({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
|
|
return (
|
|
<article className={`rounded-lg border border-[#c7d2e0] bg-white p-4 ${wide ? 'md:col-span-2' : ''}`}>
|
|
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
|
|
<p className="mt-2 text-[14px] font-bold text-[#111827]">{value}</p>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function getDisplayName(session: AuthSession): string {
|
|
const name = session.user.name?.trim();
|
|
if (name && name !== 'ATNMY Studio Admin') return name;
|
|
return session.user.email.split('@')[0] || '강사';
|
|
}
|
|
|
|
function getAffiliation(session: AuthSession): string {
|
|
const affiliation = session.user.primaryAffiliation;
|
|
if (!affiliation) return '소속 미지정';
|
|
|
|
return [affiliation.organizationName, affiliation.department, affiliation.position].filter(Boolean).join(' · ');
|
|
}
|
|
|
|
function getRoleLabel(role: string): string {
|
|
const normalized = role.toUpperCase();
|
|
if (normalized === 'LMS_ADMIN') return '관리자';
|
|
if (normalized === 'INSTRUCTOR') return '강사';
|
|
return role;
|
|
}
|