관리자 로그인

대회 승인·관리는 관리자 계정으로만 할 수 있어요.

처음이세요? 관리자 계정 만들기
관리자 이메일로만 만들 수 있어요. 랠리 앱에 이미 가입했다면 그 비밀번호로 로그인하세요.

`) w.document.close() } // 대회 게시판 백업 — 무료 요금제라 서버 자동 백업이 없어서, 한 달에 한 번 눌러 파일로 보관해요 window.downloadBackup = async () => { const all = {} for (const col of ['competitions', 'competitionNotes', 'policies']) { const r = await api('/' + col + '?pageSize=300') if (!r.ok) { toast(col + ' 백업 실패'); return } all[col] = (await r.json()).documents ?? [] } const blob = new Blob([JSON.stringify(all, null, 2)], { type: 'application/json' }) const a = document.createElement('a'); a.href = URL.createObjectURL(blob) a.download = 'rally-backup-' + todayKey() + '.json'; a.click(); URL.revokeObjectURL(a.href) await api('/competitionNotes/_backup', { method: 'PATCH', body: JSON.stringify({ fields: { lastBackupAt: { stringValue: todayKey() } } }) }) showBackupAge(todayKey()) toast('백업 파일을 받았어요 ✓') } async function load() { const res = await api('/competitions?pageSize=300') if (!res.ok) { toast('불러오기 실패'); return } const j = await res.json() // 관리자 메모는 대회 문서가 아닌 관리자 전용 칸(competitionNotes)에 둬요 — 승인된 대회 문서는 누구나 읽을 수 있어서 const notesRes = await api('/competitionNotes?pageSize=300') const notes = {} let lastBackup = null if (notesRes.ok) for (const d of ((await notesRes.json()).documents ?? [])) { const nid = d.name.split('/').pop() if (nid === '_backup') lastBackup = fromFV(d.fields?.lastBackupAt); else notes[nid] = fromFV(d.fields?.adminNote) ?? '' } showBackupAge(lastBackup) // 예전 방식(대회 문서 안 메모)이 남아 있으면 관리자 칸으로 옮기고 대회 문서에서는 지워요 for (const d of (j.documents ?? [])) { if (!d.fields || !('adminNote' in d.fields)) continue const id = d.name.split('/').pop(), old = fromFV(d.fields.adminNote) ?? '' if (old && !notes[id]) { const w = await saveNote(id, old); if (!w) continue; notes[id] = old } const del = await api(`/competitions/${id}?updateMask.fieldPaths=adminNote`, { method: 'PATCH', body: JSON.stringify({ fields: {} }) }) if (del.ok) delete d.fields.adminNote } items = (j.documents ?? []).map(d => { const f = d.fields ?? {} return { id: d.name.split('/').pop(), name: fromFV(f.name), exercise: fromFV(f.exercise), date: fromFV(f.date), location: fromFV(f.location), url: fromFV(f.url), notes: fromFV(f.notes), organizer: fromFV(f.organizer), deadline: fromFV(f.deadline), adminNote: notes[d.name.split('/').pop()] ?? '', submitterRole: fromFV(f.submitterRole), submitterEmail: fromFV(f.submitterEmail), status: fromFV(f.status), submitterName: fromFV(f.submitterName), createdAt: fromFV(f.createdAt) ?? 0, researchRequested: f.researchRequested?.booleanValue === true, } }).sort((a, b) => String(a.date).localeCompare(String(b.date))) render() } // 대회 진행 상태 — 종료(대회일 지남) · 신청마감(접수 마감일 지남) · 모집중 const todayKey = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` } function phaseOf(c) { const t = todayKey() if (c.date && String(c.date) < t) return 'ended' if (c.deadline && String(c.deadline) < t) return 'closed' return 'open' } const PHASE_LABEL = { open: '모집중', closed: '신청마감', ended: '종료' } let phase = 'all' // 공개 중 탭 필터 window.setTab = k => { tab = k; phase = 'all' for (const x of ['pending','approved','rejected']) $('tab-'+x).classList.toggle('on', x===k) render() } window.setPhase = k => { phase = k; render() } function card(c) { const ph = phaseOf(c) return `

${esc(c.name)}

${esc(c.exercise)} · ${esc(c.date)}${c.location ? ' · ' + esc(c.location) : ''}
${PHASE_LABEL[ph]}
${/flowcomp|편집샵|모음/i.test(c.url ?? '') ? `
외부 모음 사이트 링크예요 — 정책상 삭제하거나 공식 링크로 바꿔주세요
` : ''} ${c.organizer || c.deadline ? `
${esc(c.organizer ?? '')}${c.organizer && c.deadline ? ' · ' : ''}${c.deadline ? '접수 마감 ' + esc(c.deadline) : ''}
` : ''} ${c.notes ? `
${esc(c.notes)}
` : ''} ${c.adminNote ? `
관리자 메모 (유저에겐 안 보여요) — ${esc(c.adminNote)}
` : ''} ${c.url ? `링크 열어서 확인하기` : `
등록된 링크 없음
`} ${c.submitterName ? `
제보: ${esc(c.submitterName)}${c.submitterRole === 'organizer' ? ' · 주최측이라고 밝힘' : (c.submitterRole === 'public' ? ' · 일반 제보' : '')}${c.submitterEmail ? ` · ${esc(c.submitterEmail)}` : ''}
` : ''}
${c.status !== 'approved' ? `` : ''} ${c.status !== 'rejected' ? `` : ''} ${c.status === 'pending' ? `` : ''}
${c.status === 'pending' ? finder(c) : ''}
` } // 대회 데이터 찾기 — 공식 채널(인스타·공식 홈페이지)을 바로 여는 검색 링크 + 클로드 조사 요청 function finder(c) { const name = String(c.name ?? '').trim() const q = s => encodeURIComponent(s) const tag = name.replace(/[^0-9A-Za-z가-힣]/g, '') const noAgg = ' -flowcomp -플로우콤프' const links = [ ['인스타 게시물', `https://www.google.com/search?q=${q(`site:instagram.com "${name}"`)}`], ['인스타 해시태그', `https://www.instagram.com/explore/tags/${q(tag)}/`], ['공식 홈페이지', `https://www.google.com/search?q=${q(`"${name}" 요강 참가신청${noAgg}`)}`], ['네이버', `https://search.naver.com/search.naver?query=${q(`${name} 요강`)}`], ] if (c.organizer) links.push(['주최측 인스타', `https://www.google.com/search?q=${q(`site:instagram.com ${String(c.organizer).replace(/^(주최|주관)\s*/, '')}`)}`]) return `

공식 채널에서 찾아보기

${c.researchRequested ? '조사 요청됨 — 클로드에게 "대회 조사 요청 처리해줘"라고 말하면 인스타·공식 홈페이지를 찾아 날짜·장소·주최·마감·공식 링크를 채워 둘게요.' : '맡겨 두면 클로드가 다음에 모아서 조사해요. 모음 사이트(플로우콤프 등)는 쓰지 않고 주최측 공식 채널만 연결해요.'}
` } window.toggleFinder = id => $('finder-' + id).classList.toggle('open') window.requestResearch = async (id, on) => { const res = await api(`/competitions/${id}?updateMask.fieldPaths=researchRequested`, { method: 'PATCH', body: JSON.stringify({ fields: { researchRequested: { booleanValue: on } } }), }) if (res.ok) { toast(on ? '조사 요청을 남겼어요' : '요청을 취소했어요'); await load(); $('finder-' + id)?.classList.add('open') } else toast('처리 실패') } function render() { const inTab = items.filter(c => c.status === tab) const label = { pending: '승인 대기', approved: '공개 중', rejected: '거절됨' }[tab] for (const x of ['pending','approved','rejected']) { const n = items.filter(c => c.status === x).length $('tab-'+x).textContent = `${({pending:'승인 대기',approved:'공개 중',rejected:'거절됨'})[x]}${n ? ` (${n})` : ''}` } // 공개 중 탭: 모집중 · 신청마감 · 종료 필터 if (tab === 'approved') { const n = k => inTab.filter(c => phaseOf(c) === k).length $('chips').innerHTML = [['all', `전체 ${inTab.length}`], ['open', `모집중 ${n('open')}`], ['closed', `신청마감 ${n('closed')}`], ['ended', `종료 ${n('ended')}`]] .map(([k, l]) => ``).join('') $('chips').classList.remove('hidden') } else $('chips').classList.add('hidden') const rows = tab === 'approved' && phase !== 'all' ? inTab.filter(c => phaseOf(c) === phase) : inTab const upcoming = rows.filter(c => phaseOf(c) !== 'ended') // 가까운 날짜부터 const past = rows.filter(c => phaseOf(c) === 'ended').sort((a, b) => String(b.date).localeCompare(String(a.date))) // 최근 지난 것부터 $('count').textContent = `${label}${tab === 'approved' && phase !== 'all' ? ' · ' + PHASE_LABEL[phase] : ''} ${rows.length}건${past.length && upcoming.length ? ` (지난 대회 ${past.length}건)` : ''}` if (!rows.length) { $('list').innerHTML = `
${label} 대회가 없어요
`; return } $('list').innerHTML = upcoming.map(card).join('') + (past.length ? (upcoming.length ? `
지난 대회 · ${past.length}건
` : '') + past.map(card).join('') : '') } window.setStatus = async (id, status) => { const res = await api(`/competitions/${id}?updateMask.fieldPaths=status`, { method: 'PATCH', body: JSON.stringify({ fields: { status: { stringValue: status } } }), }) if (res.ok) { toast(status === 'approved' ? '승인했어요 ✓ 앱에 바로 공개돼요' : '내렸어요'); load() } else toast('처리 실패') } window.del = async (id, name) => { if (!confirm(`"${name}" 대회를 완전히 삭제할까요? 되돌릴 수 없어요.`)) return const res = await api(`/competitions/${id}`, { method: 'DELETE' }) if (res.ok) await api(`/competitionNotes/${id}`, { method: 'DELETE' }) // 메모도 같이 지워요 if (res.ok) { toast('삭제했어요'); load() } else toast('삭제 실패') } window.toggleEdit = id => $('edit-' + id).classList.toggle('open') window.saveEdit = async id => { const v = k => document.getElementById(`e-${k}-${id}`).value.trim() const fields = { name: { stringValue: v('name') }, date: { stringValue: v('date') }, location: { stringValue: v('loc') }, url: { stringValue: v('url') }, notes: { stringValue: v('notes') }, organizer: { stringValue: v('org') }, deadline: { stringValue: v('deadline') }, } const mask = ['name','date','location','url','notes','organizer','deadline'].map(f => 'updateMask.fieldPaths=' + f).join('&') const res = await api(`/competitions/${id}?${mask}`, { method: 'PATCH', body: JSON.stringify({ fields }) }) const noteOk = await saveNote(id, v('adminNote')) if (res.ok && noteOk) { toast('수정했어요 ✓'); load() } else toast('수정 실패') }