月월차 月茶
月
WOLCHA TEA CLUB

월차에 오신 것을
환영합니다

조용한 찻자리가 당신을 기다립니다.

function tastingWrite(){if(!state.user)return login();return `
TEA JOURNAL · WRITE

오늘의 시음기

잘 쓰려 하기보다, 지금 느낀 것을 편안히 기록해요.

최대 3장, 사진당 5MB · JPG, PNG, GIF, WebP
${[1,2,3,4,5].map(i=>``).join('')}
`} function accountPage(){ if(!state.user)return login(); const user=state.user; const verified=!!user.email_verified; const displayName=esc(user.nickname||user.id); const email=esc(user.email||'이메일 미등록'); return `
MY ACCOUNT · SECURITY

내 정보

나의 찻자리와 계정 보안을 관리합니다.

${user.admin?'':''}
`; } async function requestMyPasswordReset(){ const button=document.getElementById('myPasswordResetButton'); const notice=document.getElementById('myPasswordResetNotice'); if(!state.user){nav('login');return} if(!button||button.disabled)return; button.disabled=true; button.textContent='메일 발송 중...'; try{ const result=await api('password/request-me',{}); if(notice){ notice.classList.add('sent'); notice.textContent='재설정 메일을 발송했습니다. 등록된 이메일의 받은편지함과 스팸함을 확인해주세요. 링크는 30분간 유효합니다.'; } toast(result.message||'비밀번호 재설정 메일을 발송했습니다.'); let seconds=60; button.textContent='60초 후 다시 발송 가능'; const countdown=setInterval(()=>{ if(!document.body.contains(button)){clearInterval(countdown);return} seconds--; if(seconds<=0){ clearInterval(countdown); button.disabled=false; button.textContent='재설정 링크 다시 받기'; }else{ button.textContent=seconds+'초 후 다시 발송 가능'; } },1000); }catch(err){ if(notice){ notice.classList.remove('sent'); notice.textContent=err.message||'메일을 발송하지 못했습니다.'; } toast(err.message||'메일을 발송하지 못했습니다.'); button.disabled=false; button.textContent='등록된 이메일로 재설정 링크 받기'; } } function settings(){ if(!state.user)return login(); if(!state.admin)return accountPage(); const users=Array.isArray(state.users)?state.users:[]; const active=users.filter(u=>!u.disabled).length; const rows=users.map((u,i)=>{ const mine=!!u.is_self; return ` ${esc(u.nickname)}${mine?' 나':''} ${u.disabled?'사용 중지':'사용 중'} ${u.admin?'관리자':'일반 회원'} ${mine?'내 계정': ` `} `; }).join(''); const cards=users.map((u,i)=>{ const mine=!!u.is_self; return `

${esc(u.nickname)}

${u.disabled?'사용 중지':'사용 중'} ${u.admin?'관리자':'일반 회원'} ${mine?'내 계정':''}
${mine?'
현재 로그인한 관리자 계정입니다.
': `
`}
`; }).join(''); return `
ADMIN · MEMBERS

회원 관리

월차에 함께하는 회원과 권한을 관리합니다.

관리자 전용
전체 회원${users.length}명
활동 중${active}명
이메일 인증월차 회원가입 및 비밀번호 찾기 발송 설정

회원 목록

${users.length}명의 회원
${rows||''}
닉네임상태권한관리
등록된 회원이 없습니다.
${cards||'
아직 등록된 회원이 없습니다.
'}
`; } function render(){header();let route=location.hash.slice(1)||'home';let p=route.startsWith('setup/')?'setup':route.startsWith('password-reset/')?'password-reset':route;let entry=document.getElementById('entry');let showEntry=p==='home'&&!state.user;entry.hidden=!showEntry;document.body.classList.toggle('entry-active',showEntry);if(showEntry)entry.classList.remove('open');let pages={home,login,signup,setup:setupPage,'password-reset':passwordResetPage,'mail-settings':mailSettings,reset:resetPage,'find-id':findIdPage,board,'board-write':boardWrite,reviews:()=>sharedBoard('reviews'),resources:()=>sharedBoard('resources'),'reviews-write':()=>sharedBoardWrite('reviews'),'resources-write':()=>sharedBoardWrite('resources'),tasting,'tasting-write':tastingWrite,settings,account:accountPage};document.getElementById('app').innerHTML=p.startsWith('post/')?postDetailPage(p):(pages[p]||home)();document.querySelectorAll('[data-star]').forEach(b=>b.onclick=()=>{document.querySelectorAll('[data-star]').forEach(x=>x.classList.toggle('on',+x.dataset.star<=+b.dataset.star));window.rating=+b.dataset.star});if(['signup','reset','mail-settings'].includes(p))loadMailStatus()}async function api(path,body){ const options=body===undefined?{credentials:'same-origin',cache:'no-store'}: {method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),cache:'no-store'}; const res=await fetch('/api/auth/'+path,options); let payload; try{payload=await res.json()} catch(e){throw Error(res.ok?'서버 응답을 읽지 못했습니다.':'서버에 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.')} if(!res.ok)throw Error(payload.error||'서버 요청이 실패했습니다.'); return payload; } async function refreshUsers(){ if(!state.admin){state.users=[];return} try{const data=await api('users');state.users=data.users||[]} catch(e){state.users=[];toast(e.message)} } async function restoreSession(){ try{ const data=await api('me');state.user=data.user;state.admin=!!data.user?.admin; if(state.admin)await refreshUsers();await loadCommunity(false);save();render() }catch(e){state.user=null;state.admin=false;render()} } async function doLogin(){ const id=(document.getElementById('entryId')?.value||document.getElementById('loginId')?.value||'').trim(); const password=document.getElementById('entryPw')?.value||document.getElementById('loginPw')?.value||''; if(!id||!password){toast('아이디와 패스워드를 입력해 주세요.');return} try{ const data=await api('login',{id,password}); state.user=data.user;state.admin=!!data.user.admin; await refreshUsers();await loadCommunity(false);save();nav('home');toast('로그인되었습니다.') }catch(e){toast(e.message)} } let mailState={ready:false,from:'wolcha.admin@gmail.com'}; async function loadMailStatus(){ try{mailState=await api('mail/status')}catch(e){mailState={ready:false,from:'wolcha.admin@gmail.com'}} const info=document.getElementById('mailReadyInfo'); if(info)info.textContent=mailState.ready?'월차(月茶) 인증번호를 이메일로 발송합니다.':'현재 이메일 발송 서비스 설정 중입니다. 관리자에게 문의해주세요.'; const resetInfo=document.getElementById('resetMailInfo'); if(resetInfo)resetInfo.textContent=mailState.ready?'등록된 이메일로 30분 유효한 재설정 링크를 보냅니다.':'현재 이메일 발송 서비스 설정 중입니다.'; const status=document.getElementById('mailConfigStatus'); if(status)status.textContent=mailState.ready?'연결됨 · 인증 메일 발송 준비 완료':'연결 대기 · 앱 비밀번호를 입력해주세요.'; const sender=document.getElementById('mailSenderAddress'); if(sender)sender.textContent=mailState.from||'wolcha.admin@gmail.com'; const btn=document.getElementById('emailSendButton'); if(btn && !btn.dataset.cooldown)btn.disabled=!mailState.ready; const rb=document.getElementById('requestResetBtn'); if(rb)rb.disabled=!mailState.ready; return mailState; } async function sendSignupCode(){ const emailInput=document.getElementById('signupEmail'); const email=emailInput?.value.trim().toLowerCase(); if(!email||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){toast('이메일 주소를 먼저 입력해주세요.');emailInput?.focus();return} const btn=document.getElementById('emailSendButton'); if(btn){btn.disabled=true;btn.textContent='전송 중...'} try{ const result=await api('mail/send-code',{email}); toast('인증번호를 이메일로 보냈습니다.'); const helper=document.getElementById('signupCodeHelp'); if(helper)helper.textContent='인증번호를 확인해 입력해주세요. 유효시간 10분.'; let seconds=60; if(btn){btn.dataset.cooldown='true';btn.textContent='60초 후 재전송'} const timer=setInterval(()=>{ seconds--; if(!btn||!document.body.contains(btn)){clearInterval(timer);return} if(seconds<=0){clearInterval(timer);delete btn.dataset.cooldown;btn.disabled=false;btn.textContent='인증번호 재발송'} else btn.textContent=seconds+'초 후 재전송'; },1000); }catch(e){toast(e.message);if(btn){btn.disabled=!mailState.ready;btn.textContent='인증번호 발송'}} } async function requestPasswordReset(){ const identity=document.getElementById('resetIdentity')?.value.trim(); if(!identity){toast('아이디 또는 이메일을 입력해주세요.');return} const btn=document.getElementById('requestResetBtn');if(btn){btn.disabled=true;btn.textContent='전송 중...'} try{ await api('password/request',{identity}); const info=document.getElementById('resetMailInfo'); if(info)info.textContent='등록된 계정인 경우 재설정 메일이 발송됩니다. 스팸함도 확인해주세요.'; toast('계정이 확인되면 재설정 메일을 보내드립니다.'); }catch(e){toast(e.message)} finally{if(btn){btn.disabled=!mailState.ready;btn.textContent='비밀번호 재설정 메일 받기'}} } function passwordResetPage(){return `
ACCOUNT SECURITY

새 비밀번호 설정

새 비밀번호를 지정하면 이전 로그인은 자동으로 해제됩니다.

`} async function completePasswordReset(){ const token=decodeURIComponent(location.hash.slice('#password-reset/'.length)); const password=document.getElementById('resetPw')?.value; if(password!==document.getElementById('resetPw2')?.value){toast('비밀번호가 일치하지 않습니다.');return} try{ await api('password/complete',{token,password}); state.user=null;state.admin=false;state.users=[];save(); history.replaceState({},'',location.pathname+'#login');render(); toast('비밀번호가 변경되었습니다. 다시 로그인해주세요.'); }catch(e){toast(e.message)} } function mailSettings(){ if(!state.user||!state.admin)return login(); return `
ADMIN · EMAIL

이메일 발송 설정

월차 회원가입 인증번호와 비밀번호 찾기에 사용할 Gmail을 연결합니다.

발신 이메일 주소wolcha.admin@gmail.com연결 상태 확인 중...

Gmail 앱 비밀번호 연결

Google 계정에서 2단계 인증을 켠 뒤 앱 비밀번호를 발급받아 입력해주세요. 일반 Gmail 로그인 비밀번호를 입력하면 안 됩니다.

관리자만 접근할 수 있으며, 서버의 비공개 설정 파일에 저장합니다. 이 값은 화면에 다시 표시되지 않습니다.
설정 방법

① Google 계정의 2단계 인증을 활성화합니다.
② '앱 비밀번호'에서 월차 홈페이지용 비밀번호를 생성합니다.
③ 위 칸에 입력하고 연결하면 월차 메일함으로 테스트 메일을 보냅니다.

Google 앱 비밀번호 설정 열기 ↗
`; } async function configureGmail(){ if(!state.admin){toast('관리자 로그인이 필요합니다.');return} const pw=document.getElementById('gmailAppPassword')?.value||''; const btn=document.getElementById('mailConnectButton'); const result=document.getElementById('mailConnectResult'); if(btn){btn.disabled=true;btn.textContent='Gmail 연결 확인 중...'} try{ await api('mail/configure',{app_password:pw}); if(result)result.textContent='연결 성공! wolcha.admin@gmail.com으로 테스트 메일을 발송했습니다.'; const input=document.getElementById('gmailAppPassword');if(input)input.value=''; await loadMailStatus();toast('Gmail 연결이 완료되었습니다.'); }catch(e){if(result)result.textContent=e.message;toast(e.message)} finally{if(btn){btn.disabled=false;btn.textContent='Gmail 연결 및 테스트 메일 보내기'}} } async function signupDo(){ const id=document.getElementById('signupId').value.trim(); const nickname=document.getElementById('signupNickname').value.trim(); const password=document.getElementById('signupPw').value; const email=document.getElementById('signupEmail').value.trim(); const code=document.getElementById('signupCode').value.trim(); if(!/^[0-9]{6}$/.test(code)){toast('메일로 받은 6자리 인증번호를 입력해주세요.');return} if(password!==document.getElementById('signupPw2').value){toast('비밀번호가 서로 다릅니다.');return} if(!/^[A-Za-z][A-Za-z0-9_-]{3,23}$/.test(id)){toast('아이디는 영문으로 시작하는 4~24자의 영문·숫자로 입력해주세요.');return} if(!/^[가-힣A-Za-z0-9_ -]{2,20}$/.test(nickname)){toast('닉네임은 한글·영문·숫자 등 2~20자로 입력해주세요.');return} try{ await api('signup',{id,nickname,password,email,code}); toast('회원가입 완료! 아이디로 로그인해주세요.'); nav('login') }catch(e){toast(e.message)} } async function setupDo(){ const token=decodeURIComponent(location.hash.slice('#setup/'.length)); const password=document.getElementById('setupPw').value; if(password!==document.getElementById('setupPw2').value){toast('비밀번호가 일치하지 않습니다.');return} try{await api('setup',{token,password});history.replaceState({},'',location.pathname+'#login');render();toast('비밀번호 설정 완료. 로그인해주세요.')} catch(e){toast(e.message)} } function setupPage(){return `
ACCOUNT ACTIVATION

관리자 비밀번호 설정

차끓이는주인장 계정의 새 비밀번호를 지정하세요. 이 링크는 1회만 사용할 수 있습니다.

`} async function boardDo(){ if(!state.user||!state.admin){toast('공지 작성은 관리자만 할 수 있습니다.');return} const title=document.getElementById('boardTitle')?.value.trim()||''; const body=document.getElementById('boardBody')?.value.trim()||''; const form=document.getElementById('boardTitle')?.closest('form'); const submit=form?.querySelector('button[type="submit"],button:not([type])'); if(submit)submit.disabled=true; try{ await api('content/create',{kind:'board',title,body}); await loadCommunity(false); nav('board');toast('공지를 등록했습니다.'); }catch(e){toast(e.message)} finally{if(submit)submit.disabled=false} } async function noteDo(){ if(!state.user){toast('로그인이 필요합니다.');return} const title=document.getElementById('noteTitle')?.value.trim()||''; const body=document.getElementById('noteBody')?.value.trim()||''; const tea=document.getElementById('teaName')?.value.trim()||''; const origin=document.getElementById('teaOrigin')?.value.trim()||''; const rating=Math.max(1,Math.min(5,Number(window.rating)||4)); const files=Array.from(document.getElementById('tastingFiles')?.files||[]); if(files.length>3||files.some(f=>f.size<1||f.size>5*1024*1024)){toast('사진은 최대 3장, 각 5MB 이하로 선택해주세요.');return} const form=document.getElementById('noteTitle')?.closest('form'); const submit=form?.querySelector('button[type="submit"],button:not([type])'); if(submit)submit.disabled=true; try{ const attachments=await Promise.all(files.map(readAttachment)); await api('content/create',{kind:'tasting',title,body,tea,origin,rating,attachments}); await loadCommunity(false); nav('tasting');toast('시음기를 등록했습니다.'); }catch(e){toast(e.message)} finally{if(submit)submit.disabled=false} } async function toggleUser(i){ const u=state.users[i];try{await api('manage',{ref:u.ref,field:'disabled',value:!u.disabled});await refreshUsers();render()}catch(e){toast(e.message)} } async function toggleAdmin(i){ const u=state.users[i];try{await api('manage',{ref:u.ref,field:'admin',value:!u.admin});await refreshUsers();render()}catch(e){toast(e.message)} } function resetUser(i){toast('계정 비밀번호 재설정 기능은 아직 제공되지 않습니다.')} async function logout(){ try{await api('logout',{})}catch(e){toast(e.message)} state.user=null;state.admin=false;state.users=[];save();await loadCommunity(false);toast('로그아웃했습니다.');nav('home') } const entryStage=document.getElementById('entry');function revealEntryLogin(){if(entryStage.hidden||entryStage.classList.contains('open'))return;entryStage.classList.add('open');requestAnimationFrame(()=>document.getElementById('entryId').focus())}entryStage.addEventListener('click',revealEntryLogin);entryStage.addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&!entryStage.classList.contains('open')){e.preventDefault();revealEntryLogin()}}); render();loadCommunity();restoreSession();loadMailStatus();