제품명·헤더 로고 이미지 통일, 덱 이름 변경·조회 수 제거, 썸네일 홀드 미리보기를 누르는 동안만 표시하도록 수정하고 Tailwind CSS를 재빌드했다. Co-authored-by: Cursor <cursoragent@cursor.com>
511 lines
24 KiB
HTML
511 lines
24 KiB
HTML
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
|
|
<link rel="stylesheet" href="./style/output.css" />
|
|
<link rel="stylesheet" href="./style/app-shell.css" />
|
|
<link rel="stylesheet" href="./style/auth-header.css" />
|
|
<link rel="stylesheet" href="./style/profile.css" />
|
|
<title>내 프로필 - UNIARE</title>
|
|
</head>
|
|
<body class="app-page profile-page">
|
|
<nav class="app-nav">
|
|
<div class="app-nav-inner">
|
|
<a href="/" class="app-brand">
|
|
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" />
|
|
</a>
|
|
<div id="auth-header-slot"></div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="app-ad-top">
|
|
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
|
|
</div>
|
|
|
|
<div class="app-shell">
|
|
<div class="app-body-layout">
|
|
<main class="app-main profile-content">
|
|
<header class="profile-header">
|
|
<div class="page-title-row">
|
|
<h1 data-i18n="profileTitle">내 프로필</h1>
|
|
<button type="button" class="page-help-btn" data-i18n-tooltip="profileDesc" aria-expanded="false">?</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="profile-loading" class="profile-card">
|
|
<div class="profile-body">
|
|
<p data-i18n="loadingLabel" style="margin: 0; color: #64748b; font-size: 0.875rem">불러오는 중...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="profile-form-wrap" class="profile-card hidden">
|
|
<div class="profile-hero">
|
|
<div class="profile-avatar-wrap">
|
|
<img id="profile-avatar" class="profile-avatar" alt="프로필 사진" />
|
|
<button type="button" id="profile-avatar-btn" class="profile-avatar-btn" title="사진 변경">📷</button>
|
|
<input type="file" id="profile-avatar-file" accept="image/*" class="hidden" />
|
|
</div>
|
|
<div class="profile-hero-text">
|
|
<h2 id="profile-hero-name">—</h2>
|
|
<p id="profile-hero-email">—</p>
|
|
<button type="button" id="profile-avatar-remove" class="profile-avatar-remove hidden">
|
|
프로필 사진 제거
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="profile-body">
|
|
<div class="profile-field">
|
|
<label class="profile-label" for="profile-email" data-i18n="emailLabel">이메일</label>
|
|
<input type="email" id="profile-email" class="profile-input" autocomplete="email" />
|
|
</div>
|
|
<div class="profile-field">
|
|
<label class="profile-label" for="profile-username" data-i18n="usernameLabel">사용자명</label>
|
|
<input type="text" id="profile-username" class="profile-input" autocomplete="username" />
|
|
</div>
|
|
|
|
<button type="button" id="profile-save" class="profile-btn profile-btn-primary" disabled data-i18n="profileSave">
|
|
프로필 저장
|
|
</button>
|
|
<p id="profile-error" class="profile-error hidden"></p>
|
|
<p id="profile-success" class="profile-success hidden"></p>
|
|
|
|
<div class="profile-divider"></div>
|
|
|
|
<div class="profile-section-head">
|
|
<h3 class="profile-section-title" data-i18n="profileDeckSettings">덱 저장 설정</h3>
|
|
<button type="button" class="page-help-btn" data-i18n-tooltip="profileDeckSettingsDesc" aria-expanded="false">?</button>
|
|
</div>
|
|
|
|
<div class="profile-settings-panel">
|
|
<div class="profile-toggle-row">
|
|
<label class="profile-toggle-text" for="pref-public-toggle">
|
|
<span class="profile-toggle-label" data-i18n="profileDefaultPublic">기본 공개</span>
|
|
<span class="profile-toggle-hint" data-i18n="profileDefaultPublicHint">저장 시 기본으로 공개 덱에 노출</span>
|
|
</label>
|
|
<div class="profile-toggle-wrap">
|
|
<span id="pref-public-state" class="profile-toggle-state is-on" data-i18n-on="profileSettingOn" data-i18n-off="profileSettingOff">ON</span>
|
|
<button type="button" id="pref-public-toggle" class="profile-toggle is-on" aria-pressed="true">
|
|
<span id="pref-public-circle" class="profile-toggle-circle is-on"></span>
|
|
</button>
|
|
<input type="checkbox" id="pref-public-check" class="hidden" checked />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="profile-toggle-row">
|
|
<label class="profile-toggle-text" for="pref-ask-toggle">
|
|
<span class="profile-toggle-label" data-i18n="profileAskOnSave">저장 시 공개 여부 물어보기</span>
|
|
<span class="profile-toggle-hint" data-i18n="profileAskOnSaveHint">끄면 설정한 기본값으로 바로 저장</span>
|
|
</label>
|
|
<div class="profile-toggle-wrap">
|
|
<span id="pref-ask-state" class="profile-toggle-state is-on" data-i18n-on="profileSettingOn" data-i18n-off="profileSettingOff">ON</span>
|
|
<button type="button" id="pref-ask-toggle" class="profile-toggle is-on" aria-pressed="true">
|
|
<span id="pref-ask-circle" class="profile-toggle-circle is-on"></span>
|
|
</button>
|
|
<input type="checkbox" id="pref-ask-check" class="hidden" checked />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p id="profile-prefs-notice" class="profile-prefs-notice" aria-live="polite"></p>
|
|
|
|
<div class="profile-divider"></div>
|
|
|
|
<button type="button" id="profile-open-password" class="profile-btn profile-btn-outline" data-i18n="changePassword">
|
|
비밀번호 변경
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<aside class="app-ad-rail" aria-label="광고">
|
|
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar">
|
|
<ins
|
|
class="adsbygoogle"
|
|
style="display: block"
|
|
data-ad-client="ca-pub-4516420168710424"
|
|
data-ad-slot="4260332296"
|
|
data-ad-format="auto"
|
|
data-full-width-responsive="true"
|
|
></ins>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="password-modal" class="profile-modal">
|
|
<div class="profile-modal-box">
|
|
<h3 data-i18n="changePassword">비밀번호 변경</h3>
|
|
<div class="profile-field">
|
|
<label class="profile-label" for="password-current" data-i18n="passwordLabel">현재 비밀번호</label>
|
|
<input type="password" id="password-current" class="profile-input" autocomplete="current-password" />
|
|
</div>
|
|
<div class="profile-field">
|
|
<label class="profile-label" for="password-new" data-i18n="newPasswordLabel">새 비밀번호</label>
|
|
<input type="password" id="password-new" class="profile-input" autocomplete="new-password" />
|
|
</div>
|
|
<div class="profile-field">
|
|
<label class="profile-label" for="password-confirm" data-i18n="confirmPasswordLabel">새 비밀번호 확인</label>
|
|
<input type="password" id="password-confirm" class="profile-input" autocomplete="new-password" />
|
|
</div>
|
|
<p id="password-error" class="profile-error hidden"></p>
|
|
<div class="profile-modal-actions">
|
|
<button type="button" id="password-cancel" class="profile-btn profile-btn-outline" data-i18n="cancel">취소</button>
|
|
<button type="button" id="password-save" class="profile-btn profile-btn-primary" data-i18n="changePasswordButton">변경</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/script/api.js"></script>
|
|
<script src="/script/ads.js"></script>
|
|
<script src="/script/page-i18n.js"></script>
|
|
<script src="/script/page-help.js"></script>
|
|
<script src="/script/deck-prefs.js"></script>
|
|
<script src="/script/image-utils.js"></script>
|
|
<script src="/script/auth-header.js"></script>
|
|
<script>
|
|
const DEFAULT_AVATAR =
|
|
'data:image/svg+xml,' +
|
|
encodeURIComponent(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>',
|
|
);
|
|
|
|
let currentUser = null;
|
|
let pendingAvatarImage = null;
|
|
let pendingRemoveAvatar = false;
|
|
/** @type {{ email: string, username: string, hasAvatar: boolean }} */
|
|
let baseline = { email: '', username: '', hasAvatar: false };
|
|
/** @type {number|null} */
|
|
let prefsNoticeTimer = null;
|
|
|
|
/**
|
|
* @param {string} key
|
|
* @param {string} fallback
|
|
* @returns {string}
|
|
*/
|
|
function t(key, fallback) {
|
|
return window.UaPageI18n?.t(key, fallback) || fallback;
|
|
}
|
|
|
|
/**
|
|
* 프로필 토글 UI
|
|
* @param {string} toggleId
|
|
* @param {string} circleId
|
|
* @param {string} checkboxId
|
|
* @param {string|null} stateId
|
|
* @param {boolean} on
|
|
*/
|
|
function setProfileToggle(toggleId, circleId, checkboxId, stateId, on) {
|
|
const toggle = document.getElementById(toggleId);
|
|
const circle = document.getElementById(circleId);
|
|
const checkbox = document.getElementById(checkboxId);
|
|
const stateEl = stateId ? document.getElementById(stateId) : null;
|
|
if (!toggle || !circle || !checkbox) return;
|
|
checkbox.checked = on;
|
|
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
toggle.classList.toggle('is-on', on);
|
|
circle.classList.toggle('is-on', on);
|
|
if (stateEl) {
|
|
const onLabel = stateEl.dataset.i18nOn
|
|
? t(stateEl.dataset.i18nOn, 'ON')
|
|
: 'ON';
|
|
const offLabel = stateEl.dataset.i18nOff
|
|
? t(stateEl.dataset.i18nOff, 'OFF')
|
|
: 'OFF';
|
|
stateEl.textContent = on ? onLabel : offLabel;
|
|
stateEl.classList.toggle('is-on', on);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 덱 저장 설정 UI 반영
|
|
* @param {object} user
|
|
*/
|
|
function fillDeckPrefs(user) {
|
|
const prefs = UaDeckPrefs.getPrefs(user);
|
|
setProfileToggle(
|
|
'pref-public-toggle',
|
|
'pref-public-circle',
|
|
'pref-public-check',
|
|
'pref-public-state',
|
|
prefs.defaultPublic,
|
|
);
|
|
setProfileToggle(
|
|
'pref-ask-toggle',
|
|
'pref-ask-circle',
|
|
'pref-ask-check',
|
|
'pref-ask-state',
|
|
!prefs.skipPrompt,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 덱 저장 설정 적용 안내
|
|
*/
|
|
function showPrefsSavedNotice() {
|
|
const el = document.getElementById('profile-prefs-notice');
|
|
if (!el) return;
|
|
el.textContent = t('profileDeckPrefsSaved', '덱 저장 설정이 적용되었습니다.');
|
|
if (prefsNoticeTimer) clearTimeout(prefsNoticeTimer);
|
|
prefsNoticeTimer = window.setTimeout(() => {
|
|
el.textContent = '';
|
|
}, 2500);
|
|
}
|
|
|
|
/**
|
|
* 덱 저장 설정 저장
|
|
*/
|
|
async function persistDeckPrefs() {
|
|
const prefs = {
|
|
defaultPublic: document.getElementById('pref-public-check').checked,
|
|
skipPrompt: !document.getElementById('pref-ask-check').checked,
|
|
};
|
|
await UaDeckPrefs.savePrefs(prefs);
|
|
const cached = UaDeckApi.getCachedUser();
|
|
if (cached) {
|
|
cached.defaultDeckPublic = prefs.defaultPublic;
|
|
cached.skipDeckPublicPrompt = prefs.skipPrompt;
|
|
UaDeckApi.saveAuth(UaDeckApi.getToken(), cached);
|
|
}
|
|
showPrefsSavedNotice();
|
|
}
|
|
|
|
/**
|
|
* 저장 버튼 활성화 여부 갱신
|
|
*/
|
|
function updateSaveButton() {
|
|
const email = document.getElementById('profile-email').value.trim();
|
|
const username = document.getElementById('profile-username').value.trim();
|
|
const dirty =
|
|
email !== baseline.email ||
|
|
username !== baseline.username ||
|
|
pendingAvatarImage !== null ||
|
|
(pendingRemoveAvatar && baseline.hasAvatar);
|
|
const btn = document.getElementById('profile-save');
|
|
btn.disabled = !dirty;
|
|
}
|
|
|
|
/**
|
|
* 저장 기준값 설정 (저장 성공·초기 로드 후)
|
|
* @param {object} user
|
|
*/
|
|
function setBaseline(user) {
|
|
baseline = {
|
|
email: user.email || '',
|
|
username: user.username || '',
|
|
hasAvatar: Boolean(user.avatarUrl),
|
|
};
|
|
pendingAvatarImage = null;
|
|
pendingRemoveAvatar = false;
|
|
updateSaveButton();
|
|
}
|
|
|
|
/**
|
|
* 아바타 URL (캐시 무효화 포함)
|
|
* @param {object|null} user
|
|
* @returns {string}
|
|
*/
|
|
function resolveAvatarSrc(user) {
|
|
const raw = user?.avatarUrl;
|
|
if (!raw) return DEFAULT_AVATAR;
|
|
let url = UaDeckApi.resolveMediaUrl(raw) || raw;
|
|
if (user?.updatedAt && !url.startsWith('data:')) {
|
|
url += (url.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(user.updatedAt);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* 폼에 사용자 정보 반영
|
|
* @param {object} user
|
|
*/
|
|
function fillForm(user) {
|
|
document.getElementById('profile-hero-name').textContent = user.username || '—';
|
|
document.getElementById('profile-hero-email').textContent = user.email || '';
|
|
document.getElementById('profile-email').value = user.email || '';
|
|
document.getElementById('profile-username').value = user.username || '';
|
|
document.getElementById('profile-avatar').src = resolveAvatarSrc(user);
|
|
document.getElementById('profile-avatar-remove').classList.toggle('hidden', !user.avatarUrl);
|
|
}
|
|
|
|
/**
|
|
* 메시지 표시
|
|
* @param {'error'|'success'} type
|
|
* @param {string} text
|
|
*/
|
|
function showMessage(type, text) {
|
|
const errEl = document.getElementById('profile-error');
|
|
const okEl = document.getElementById('profile-success');
|
|
errEl.classList.add('hidden');
|
|
okEl.classList.add('hidden');
|
|
if (!text) return;
|
|
const el = type === 'error' ? errEl : okEl;
|
|
el.textContent = text;
|
|
el.classList.remove('hidden');
|
|
}
|
|
|
|
async function init() {
|
|
if (window.UaPageI18n?.applyPageI18n) {
|
|
await window.UaPageI18n.applyPageI18n();
|
|
}
|
|
|
|
if (!UaDeckApi.getToken()) {
|
|
location.href = '/login.html?redirect=' + encodeURIComponent('/profile.html');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { user } = await UaDeckApi.getMe();
|
|
currentUser = user;
|
|
UaDeckApi.saveAuth(UaDeckApi.getToken(), user);
|
|
fillForm(user);
|
|
setBaseline(user);
|
|
fillDeckPrefs(user);
|
|
document.getElementById('profile-loading').classList.add('hidden');
|
|
document.getElementById('profile-form-wrap').classList.remove('hidden');
|
|
} catch (err) {
|
|
document.getElementById('profile-loading').innerHTML =
|
|
'<div class="profile-body"><p class="profile-error" style="margin:0">' +
|
|
(err.message || '프로필을 불러올 수 없습니다.') +
|
|
'</p></div>';
|
|
}
|
|
}
|
|
|
|
document.getElementById('profile-email').addEventListener('input', updateSaveButton);
|
|
document.getElementById('profile-username').addEventListener('input', updateSaveButton);
|
|
|
|
document.getElementById('pref-public-toggle').addEventListener('click', async () => {
|
|
const checkbox = document.getElementById('pref-public-check');
|
|
setProfileToggle(
|
|
'pref-public-toggle',
|
|
'pref-public-circle',
|
|
'pref-public-check',
|
|
'pref-public-state',
|
|
!checkbox.checked,
|
|
);
|
|
await persistDeckPrefs();
|
|
});
|
|
|
|
document.getElementById('pref-ask-toggle').addEventListener('click', async () => {
|
|
const checkbox = document.getElementById('pref-ask-check');
|
|
setProfileToggle(
|
|
'pref-ask-toggle',
|
|
'pref-ask-circle',
|
|
'pref-ask-check',
|
|
'pref-ask-state',
|
|
!checkbox.checked,
|
|
);
|
|
await persistDeckPrefs();
|
|
});
|
|
|
|
document.querySelectorAll('.profile-toggle-text').forEach((label) => {
|
|
label.addEventListener('click', (e) => {
|
|
const forId = label.getAttribute('for');
|
|
if (!forId) return;
|
|
const btn = document.getElementById(forId);
|
|
if (btn && e.target !== btn) btn.click();
|
|
});
|
|
});
|
|
|
|
document.getElementById('profile-avatar-btn').addEventListener('click', () => {
|
|
document.getElementById('profile-avatar-file').click();
|
|
});
|
|
|
|
document.getElementById('profile-avatar-file').addEventListener('change', async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
pendingAvatarImage = await UaImageUtils.resizeImageToPng(file);
|
|
pendingRemoveAvatar = false;
|
|
document.getElementById('profile-avatar').src = pendingAvatarImage;
|
|
document.getElementById('profile-avatar-remove').classList.remove('hidden');
|
|
showMessage('success', '사진이 선택되었습니다.「프로필 저장」을 눌러 적용하세요.');
|
|
updateSaveButton();
|
|
} catch (err) {
|
|
showMessage('error', err.message || '이미지 처리에 실패했습니다.');
|
|
}
|
|
e.target.value = '';
|
|
});
|
|
|
|
document.getElementById('profile-avatar-remove').addEventListener('click', () => {
|
|
pendingAvatarImage = null;
|
|
pendingRemoveAvatar = true;
|
|
document.getElementById('profile-avatar').src = DEFAULT_AVATAR;
|
|
document.getElementById('profile-avatar-remove').classList.add('hidden');
|
|
showMessage('success', '사진 제거가 예약되었습니다.「프로필 저장」을 눌러 적용하세요.');
|
|
updateSaveButton();
|
|
});
|
|
|
|
document.getElementById('profile-save').addEventListener('click', async () => {
|
|
showMessage('error', '');
|
|
showMessage('success', '');
|
|
const body = {
|
|
email: document.getElementById('profile-email').value.trim(),
|
|
username: document.getElementById('profile-username').value.trim(),
|
|
};
|
|
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;
|
|
if (pendingRemoveAvatar) body.removeAvatar = true;
|
|
|
|
try {
|
|
const { user } = await UaDeckApi.updateProfile(body);
|
|
currentUser = user;
|
|
UaDeckApi.saveAuth(UaDeckApi.getToken(), user);
|
|
fillForm(user);
|
|
setBaseline(user);
|
|
if (window.UaDeckAuth?.refresh) await window.UaDeckAuth.refresh();
|
|
showMessage('success', '프로필이 저장되었습니다.');
|
|
} catch (err) {
|
|
showMessage('error', err.message || '저장에 실패했습니다.');
|
|
}
|
|
});
|
|
|
|
const passwordModal = document.getElementById('password-modal');
|
|
|
|
document.getElementById('profile-open-password').addEventListener('click', () => {
|
|
document.getElementById('password-current').value = '';
|
|
document.getElementById('password-new').value = '';
|
|
document.getElementById('password-confirm').value = '';
|
|
document.getElementById('password-error').classList.add('hidden');
|
|
passwordModal.classList.add('open');
|
|
});
|
|
|
|
document.getElementById('password-cancel').addEventListener('click', () => {
|
|
passwordModal.classList.remove('open');
|
|
});
|
|
|
|
document.getElementById('password-save').addEventListener('click', async () => {
|
|
const errEl = document.getElementById('password-error');
|
|
errEl.classList.add('hidden');
|
|
const current = document.getElementById('password-current').value;
|
|
const pw = document.getElementById('password-new').value;
|
|
const confirm = document.getElementById('password-confirm').value;
|
|
if (pw.length < 8) {
|
|
errEl.textContent = '비밀번호는 8자 이상이어야 합니다.';
|
|
errEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
if (pw !== confirm) {
|
|
errEl.textContent = '새 비밀번호가 일치하지 않습니다.';
|
|
errEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
try {
|
|
await UaDeckApi.changePassword(current, pw);
|
|
passwordModal.classList.remove('open');
|
|
showMessage('success', '비밀번호가 변경되었습니다.');
|
|
} catch (err) {
|
|
errEl.textContent = err.message || '비밀번호 변경에 실패했습니다.';
|
|
errEl.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
passwordModal.addEventListener('click', (e) => {
|
|
if (e.target === passwordModal) passwordModal.classList.remove('open');
|
|
});
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|