platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-07-07 09:15:47 +00:00
parent 1bf81f86bb
commit d029bdeda7
59 changed files with 12096 additions and 2 deletions
+199
View File
@@ -0,0 +1,199 @@
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;
}