🚨 데이터 오류 발생

불러온 데이터(JSON)에 문제가 있거나, 구조가 맞지 않아 앱을 실행할 수 없습니다.

`); w.document.close(); } function saveData() { localStorage.setItem(STORAGE_KEY, JSON.stringify(DATA)); } window.resetData = window.hardReset; function blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); } function dataUrlToBlob(dataUrl) { const [meta, base64] = dataUrl.split(','); const mime = (/data:(.*?);base64/.exec(meta || '') || [])[1] || 'application/octet-stream'; const binary = atob(base64 || ''); const arr = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i); return new Blob([arr], { type: mime }); } async function importAssetsFromBackup(items) { if (!Array.isArray(items) || !items.length) return; const db = await openAssetDb(); const tx = db.transaction(ASSET_STORE, 'readwrite'); const store = tx.objectStore(ASSET_STORE); for (const item of items) { if (!item || !item.id || !item.dataUrl) continue; await txRequest(store.put({ id: item.id, name: item.name || item.id, type: item.type || 'application/octet-stream', size: item.size || 0, hash: item.hash || '', createdAt: item.createdAt || Date.now(), blob: dataUrlToBlob(item.dataUrl) })); } } window.exportData = function() { (async () => { const payload = JSON.parse(JSON.stringify(DATA)); const assets = await listAssetRecords(); if (assets.length) { payload.__assets = await Promise.all(assets.map(async a => ({ id: a.id, name: a.name, type: a.type, size: a.size, hash: a.hash, createdAt: a.createdAt, dataUrl: await blobToDataUrl(a.blob) }))); } const dataStr = JSON.stringify(payload, null, 2); const blob = new Blob([dataStr], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `planner_backup_${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); })().catch(err => alert('백업 저장 실패: ' + err.message)); } window.triggerImport = function() { document.getElementById('fileInput').click(); } window.loadJsonFile = function(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async function(e) { try { const json = JSON.parse(e.target.result); if(!json.home || !json.details) throw new Error("필수 데이터(home, details)가 누락되었습니다."); if (confirm(`"${file.name}" 파일을 불러오시겠습니까?`)) { DATA = json; const backupAssets = DATA.__assets; if (backupAssets) delete DATA.__assets; if(!DATA.home.dDays) DATA.home.dDays = []; if (backupAssets && backupAssets.length) await importAssetsFromBackup(backupAssets); saveData(); renderRouter(); alert("성공적으로 복원되었습니다!"); } } catch (err) { alert("파일을 읽을 수 없습니다.\n" + err.message); } }; reader.readAsText(file); event.target.value = ''; } // [Routing] function getRoute() { const hash = window.location.hash || '#/'; const [path, query] = hash.replace(/^#/, '').split('?'); const parts = path.split('/').filter(Boolean); const params = new URLSearchParams(query); return { page: parts[0] || 'home', id: parts[1], focus: params.get('focus') }; } function router() { const route = getRoute(); try { if (route.page === 'detail' && route.id && !DATA.details[route.id]) { DATA.details[route.id] = { id: route.id, title: "새 과목 상세", subtitle: "토픽을 추가하세요", filters: [], cards: [] }; } if (route.page === 'module' && route.id && !DATA.modules[route.id]) { DATA.modules[route.id] = { id: route.id, title: "새 학습 모듈", subtitle: "이론 및 문제를 추가하세요", backTo: "#/", tabs: [{ id: "tab1", label: "기본 이론", theoryNote: "", questions: [] }] }; } if (route.page === 'home') renderHome(); else if (route.page === 'detail') renderDetail(route.id); else if (route.page === 'module') renderModule(route.id); setTimeout(() => { applyMath(); setupMarkdownEditors(); if (route.focus) { const el = document.getElementById(`card-${route.focus}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlighted'); setTimeout(() => el.classList.remove('highlighted'), 1500); } } else window.scrollTo(0,0); }, 50); } catch(e) { showErrorScreen("페이지 로드 실패: " + e.message); } } // [Renderers] function renderHome() { const totalStats = calcTotalStats(); const ddays = DATA.home.dDays || []; const ddayHtml = ddays.map(d => { if(!d.date) return ''; const target = new Date(d.date).setHours(0,0,0,0); const today = new Date().setHours(0,0,0,0); const diff = (target - today) / (1000 * 60 * 60 * 24); let dLabel = diff > 0 ? `D-${Math.ceil(diff)}` : (diff === 0 ? "D-Day" : `D+${Math.abs(Math.ceil(diff))}`); return `
${d.label}${dLabel}
`; }).join(''); APP.innerHTML = `
${ddayHtml} ${ddays.length === 0 ? '
+ D-Day 추가
' : ''}

${APP_NAME}

${DATA.home.subtitle}

${renderStickyHeader('전체 진행률', totalStats.pct, totalStats.timeStr)}
${DATA.home.subjects.map((sub, idx) => { const detailStats = calcSubjectStats(sub.id); const detailCards = DATA.details[sub.id] ? DATA.details[sub.id].cards : []; return `
${sub.badge}

${sub.name}

${sub.examInfo}

${detailCards.map((c, cIdx) => { const cStats = calcCardStats(c); const isDone = cStats.isComplete; return `
${c.title}
`; }).join('')} ${detailCards.length===0 ? '(토픽 없음)' : ''}
${detailStats.timeStr}
`}).join('')}
+
새 과목 추가
`; setupDragDrop('home'); } function renderDetail(sid) { const detail = DATA.details[sid]; const stats = calcSubjectStats(sid); const seen = new Set(); const badgeList = []; let hasNone = false; (detail.cards || []).forEach(c => { const tags = badgeTags(c.badge); if (tags.length === 0) { hasNone = true; return; } tags.forEach(t => { if (!seen.has(t)) { seen.add(t); badgeList.push(t); } }); }); const filters = [{ key: 'all', label: '전체보기' }, ...badgeList.map(b => ({ key: b, label: b })), ...(hasNone ? [{ key: '__none__', label: '미분류' }] : [])]; APP.innerHTML = `

${detail.title}

${detail.subtitle}

${renderStickyHeader('과목 진행률', stats.pct, stats.timeStr)}
${filters.map(f => ``).join('')}
선택 ${getSelectedIndices('detail', sid, '').length}개
${detail.cards.map((c, i) => renderCardItem(c, 'detail', sid, i)).join('')}
+
새 카드 추가
`; setupDragDrop('detail', sid); } function renderModule(mid) { const mod = DATA.modules[mid]; const activeTabId = mod.activeTab || (mod.tabs[0] ? mod.tabs[0].id : null); const activeTab = mod.tabs.find(t => t.id === activeTabId) || mod.tabs[0]; const stats = calcModuleStats(mid); if(!activeTab.theoryNote) activeTab.theoryNote = ""; const theoryId = `theory-${mid}-${activeTabId}`; APP.innerHTML = `

${mod.title}

${mod.subtitle}

${renderStickyHeader('모듈 진행률', stats.pct, stats.timeStr)}
${mod.tabs.map(t => ``).join('')}

📌 ${activeTab.label}

${renderMd(activeTab.theoryNote || "")}
${formatTime(activeTab.time || 0)}
선택 ${getSelectedIndices('module', mid, activeTabId).length}개
${activeTab.questions.map((q, i) => renderCardItem(q, 'module', mid, i, activeTabId)).join('')}
+
새 문제 추가
`; setupDragDrop('module', mid, activeTabId); setupMarkdownEditors(); } function renderCardItem(item, level, pid, idx, tabId = null) { const isMod = level === 'module'; const stableId = item.id || (level + '-' + pid + '-' + idx); const userNote = item.userNote || ""; let subItemsHtml = ''; let isDone = item.done; let totalTime = item.time || 0; if (!isMod && item.module && DATA.modules[item.module]) { const mStats = calcModuleStats(item.module); const moduleTime = mStats.total > 0 ? mStats.rawTime : 0; totalTime = (item.time || 0) + moduleTime; if (mStats.total > 0) { isDone = mStats.isComplete; } else { isDone = item.done; } const modTabs = DATA.modules[item.module].tabs; subItemsHtml = `
`; modTabs.forEach(t => { subItemsHtml += `
${t.label} (이론)
`; t.questions.forEach((q, qIdx) => { const qLabel = q.src || (q.text.length > 15 ? q.text.substring(0,15)+'...' : q.text); // [Fix: Link to Module Question] subItemsHtml += `
${qLabel}
`; }); }); subItemsHtml += `
`; } let linkLabel = item.title; if (isMod) linkLabel = item.src ? item.src : (item.text.length>20 ? item.text.substring(0,20)+'...' : item.text); return `
${isMod ? (item.src || '문항') : (item.badge || 'Topic')}
${!isMod && item.module ? `` : ''}
${isMod ? `
${renderMd(item.text)}
` : `

${item.title}

${item.scope || ''}

` } ${item.qbox ? `
💡 ${renderMd(item.qbox)}
` : ''}
${!isMod ? subItemsHtml : ''} ${isMod ? `
📝 오답/필기 노트 (클릭하여 수정)
${renderMd(userNote)}
` : ''}
${formatTime(totalTime)}
`; } // [Logic] function formatTime(s) { const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sc = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sc).padStart(2, '0')}`; } function toggleDetailCardStatus(pid, idx, status) { if(!DATA.details[pid]) return; const card = DATA.details[pid].cards[idx]; card.done = status; if (status) card.isRunning = false; if(card.module && DATA.modules[card.module]) { const mod = DATA.modules[card.module]; mod.tabs.forEach(t => { t.done = status; t.questions.forEach(q => q.done = status); }); } saveData(); renderRouter(); } function calcCardStats(card) { let ownTime = card.time || 0; if (card.module && DATA.modules[card.module]) { const mStats = calcModuleStats(card.module); return { isComplete: mStats.isComplete, time: ownTime + mStats.rawTime }; } return { isComplete: card.done, time: ownTime }; } function calcModuleStats(mid) { let total=0, done=0, time=0; if(DATA.modules && DATA.modules[mid]) { DATA.modules[mid].tabs.forEach(t => { total++; if(t.done) done++; time += (t.time || 0); t.questions.forEach(q => { total++; if(q.done) done++; time += (q.time||0); }); }); } return { pct: total?Math.round((done/total)*100):0, timeStr: formatTime(time), rawTime: time, isComplete: (total>0 && total===done), total }; } function calcSubjectStats(sid) { let ownTime = 0; const subj = DATA.home.subjects.find(s => s.id === sid); if (subj) ownTime = subj.time || 0; if (!DATA.details[sid]) return { pct: 0, timeStr: formatTime(ownTime), rawTime: ownTime }; const cards = DATA.details[sid].cards; let detailTime = 0; let doneCount = 0; cards.forEach(c => { const cStat = calcCardStats(c); detailTime += cStat.time; if(cStat.isComplete) doneCount++; }); const totalTime = ownTime + detailTime; return { pct: cards.length ? Math.round((doneCount/cards.length)*100) : 0, timeStr: formatTime(totalTime), rawTime: totalTime }; } function calcTotalStats() { let t=0, d=0; let totalTime = 0; DATA.home.subjects.forEach(sub => { const stats = calcSubjectStats(sub.id); totalTime += stats.rawTime; }); if(DATA.details) Object.values(DATA.details).forEach(x=>{ t += x.cards.length; x.cards.forEach(c => { if(calcCardStats(c).isComplete) d++; }); }); return {pct: t?Math.round((d/t)*100):0, timeStr: formatTime(totalTime)}; } function getSubjectTime(sid) { return calcSubjectStats(sid).timeStr; } // [Toggle & Save] function toggleSubItem(mid, type, tid, qIdx, checked) { if (!DATA.modules[mid]) return; const tab = DATA.modules[mid].tabs.find(t => t.id === tid); if (!tab) return; if (type === 'theory') tab.done = checked; else if (type === 'question') tab.questions[qIdx].done = checked; saveData(); renderRouter(); } function setAllChildStatus(level, pid, idx, status) { if (level === 'subject') { if(DATA.details[pid]) DATA.details[pid].cards.forEach((card, i) => setAllChildStatus('detail', pid, i, status)); } else if (level === 'detail') { const card = DATA.details[pid].cards[idx]; if (card.module && DATA.modules[card.module]) { const mod = DATA.modules[card.module]; mod.tabs.forEach(t => { t.done = status; t.questions.forEach(q => q.done = status); }); } else { card.done = status; } } } function toggleCheckAll(level, pid, checked) { setAllChildStatus(level, pid, null, checked); saveData(); renderRouter(); } function toggleCheck(chk, lvl, pid, idx, tid) { const status = chk.checked; if(lvl==='detail') { toggleDetailCardStatus(pid, idx, status); } else if(lvl==='theory') { const item = DATA.modules[pid].tabs.find(t=>t.id===tid); item.done = status; if (status) item.isRunning = false; } else { const item = DATA.modules[pid].tabs.find(t=>t.id===tid).questions[idx]; item.done = status; if (status) item.isRunning = false; } saveData(); renderRouter(); } function toggleTimer(lvl, pid, idx, tid) { let item; if(lvl==='subject') item = DATA.home.subjects[idx]; else if(lvl==='theory') item = DATA.modules[pid].tabs.find(t=>t.id===tid); else if(lvl==='detail') item = DATA.details[pid].cards[idx]; else item = DATA.modules[pid].tabs.find(t=>t.id===tid).questions[idx]; item.isRunning = !item.isRunning; saveData(); renderRouter(); } function editTime(lvl, pid, idx, tid) { let item; if(lvl==='subject') item = DATA.home.subjects[idx]; else if(lvl==='theory') item = DATA.modules[pid].tabs.find(t=>t.id===tid); else if(lvl==='detail') item = DATA.details[pid].cards[idx]; else item = DATA.modules[pid].tabs.find(t=>t.id===tid).questions[idx]; const minStr = prompt("수정할 시간을 분(minute) 단위로 입력하세요:", Math.floor((item.time||0)/60)); if(minStr === null) return; const min = parseInt(minStr); if(!isNaN(min)) { item.time = min * 60; saveData(); renderRouter(); } } function resetAccumulatedTime(level, pid, idx, tabId) { const clearItemTime = (item) => { if(item){ item.time = 0; item.isRunning = false; } }; if (level === 'all') { if (!confirm('모든 카드의 누적 시간을 초기화하시겠습니까?')) return; (DATA.home?.subjects || []).forEach(clearItemTime); Object.values(DATA.details || {}).forEach(d => (d.cards || []).forEach(clearItemTime)); Object.values(DATA.modules || {}).forEach(m => (m.tabs || []).forEach(t => { clearItemTime(t); (t.questions || []).forEach(clearItemTime); })); } else { let target = null; if(level === 'subject') target = DATA.home?.subjects?.[idx]; else if(level === 'detail') target = DATA.details?.[pid]?.cards?.[idx]; else if(level === 'theory') target = DATA.modules?.[pid]?.tabs?.find(t => String(t.id) === String(tabId)); else if(level === 'module') { const tab = DATA.modules?.[pid]?.tabs?.find(t => String(t.id) === String(tabId)) || DATA.modules?.[pid]?.tabs?.[0]; target = tab?.questions?.[idx]; } if(!target) return; if(!confirm('이 카드의 누적 시간을 초기화하시겠습니까?')) return; clearItemTime(target); } saveData(); if (typeof renderRouter === 'function') renderRouter(true); if (typeof renderDailySidebar === 'function') renderDailySidebar(); } function tickTimers() { let chg = false; const run = (obj) => { if(obj.isRunning){ obj.time=(obj.time||0)+1; chg=true; }}; if(!DATA) return; if(DATA.home.subjects) DATA.home.subjects.forEach(s => run(s)); if(DATA.details) Object.values(DATA.details).forEach(d => d.cards.forEach(c => run(c))); if(DATA.modules) Object.values(DATA.modules).forEach(m => m.tabs.forEach(t => { run(t); t.questions.forEach(q => run(q)); })); if(chg) { saveData(); updateTimerDOM(); } } function updateTimerDOM() { const update = (id, val) => { const el=document.getElementById(id); if(el) el.innerText=formatTime(val); }; DATA.home.subjects.forEach(s => update(`time-disp-subject-${s.id}`, calcSubjectStats(s.id).rawTime)); Object.keys(DATA.modules).forEach(mid => { DATA.modules[mid].tabs.forEach(t => { update(`time-disp-theory-${mid}-${t.id}`, t.time||0); t.questions.forEach((q,i) => update(`time-disp-${q.id||('module-'+mid+'-'+i)}`, q.time||0)); }); }); Object.keys(DATA.details).forEach(did => { DATA.details[did].cards.forEach((c,i) => update(`time-disp-${c.id||('detail-'+did+'-'+i)}`, calcCardStats(c).time)); }); } function toggleNoteView(uniqueId) { const view = document.getElementById(`view-${uniqueId}`); const edit = document.getElementById(`edit-${uniqueId}`); const ta = document.getElementById(`ta-${uniqueId}`); if (edit.style.display === 'none' || edit.style.display === '') { view.style.display = 'none'; edit.style.display = 'block'; if(ta) ta.focus(); } else { edit.style.display = 'none'; view.style.display = 'block'; } } function saveNoteText(type, pid, idx, tabId, domId) { const text = document.getElementById(`ta-${domId}`).value; if(type === 'theory') { DATA.modules[pid].tabs.find(t=>t.id===tabId).theoryNote = text; saveData(); renderModule(pid); } else { DATA.modules[pid].tabs.find(t=>t.id===tabId).questions[idx].userNote = text; const view = document.getElementById(`view-${domId}`); view.innerHTML = renderMd(text); view.setAttribute('data-empty', !text); toggleNoteView(domId); applyMath(); saveData(); } } function openAddModal(mode, pid, tabId) { openEditModal(mode, pid, undefined, tabId); } let editState = null; function openEditModal(mode, pid, idx, tabId, pageId) { editState = { mode, pid, idx, tabId, pageId }; document.getElementById('editModal').style.display = 'flex'; document.getElementById('grpBadge').style.display = 'block'; document.getElementById('grpQBox').style.display = 'none'; const title = document.getElementById('editTitle'); const desc = document.getElementById('editDesc'); const badge = document.getElementById('editBadge'); const qbox = document.getElementById('editQBox'); if (mode === 'subject' && idx !== undefined) { const i = DATA.home.subjects[idx]; title.value=i.name; desc.value=i.examInfo; badge.value=i.badge; } else if (mode === 'subject') { title.value=""; desc.value=""; badge.value="New"; } else if (mode === 'app_info') { title.value=DATA.home.title; desc.value=DATA.home.subtitle; document.getElementById('grpBadge').style.display='none'; } else if (mode === 'page_info') { const d = pageId in DATA.details ? DATA.details[pageId] : DATA.modules[pageId]; title.value=d.title; desc.value=d.subtitle; document.getElementById('grpBadge').style.display='none'; } else if (mode === 'tab_rename') { title.value=DATA.modules[pid].tabs.find(x=>x.id===tabId).label; desc.parentElement.style.display='none'; document.getElementById('grpBadge').style.display='none'; } else if (mode === 'tab_add') { title.value=""; desc.parentElement.style.display='none'; document.getElementById('grpBadge').style.display='none'; } else if (mode === 'detail') { document.getElementById('grpQBox').style.display = 'block'; if(idx!==undefined) { const c=DATA.details[pid].cards[idx]; title.value=c.title; desc.value=c.scope; badge.value=c.badge; qbox.value=c.qbox || ""; } else { title.value=""; desc.value=""; badge.value="Topic"; qbox.value=""; } } else if (mode === 'module') { if(idx!==undefined) { const q=DATA.modules[pid].tabs.find(x=>x.id===tabId).questions[idx]; title.value=q.text; desc.value=q.src; document.getElementById('grpBadge').style.display='none'; } else { title.value=""; desc.value=""; document.getElementById('grpBadge').style.display='none'; } } } function closeModal() { document.getElementById('editModal').style.display='none'; document.getElementById('editDesc').parentElement.style.display='block'; } function saveCardChange() { const t = document.getElementById('editTitle').value; const d = document.getElementById('editDesc').value; const b = document.getElementById('editBadge').value; const q = document.getElementById('editQBox').value; const { mode, pid, idx, tabId, pageId } = editState; if (mode === 'app_info') { DATA.home.title = APP_NAME; DATA.home.subtitle = d; } else if (mode === 'page_info') { if(DATA.details[pageId]) { DATA.details[pageId].title = t; DATA.details[pageId].subtitle = d; } else { DATA.modules[pageId].title = t; DATA.modules[pageId].subtitle = d; } } else if (mode === 'tab_rename') { DATA.modules[pid].tabs.find(x=>x.id===tabId).label = t; } else if (mode === 'tab_add') { DATA.modules[pid].tabs.push({id: Date.now().toString(), label: t, theoryNote:"", questions:[]}); } else if (mode === 'subject') { if (idx === undefined) { const nid='s_'+Date.now(); DATA.home.subjects.push({ id: nid, name: t, examInfo: d, badge: b }); DATA.details[nid]={id:nid, title:t+" 상세", subtitle:"", cards:[]}; } else { const s=DATA.home.subjects[idx]; s.name=t; s.examInfo=d; s.badge=b; } } else if (mode === 'detail') { const list = DATA.details[pid].cards; if (idx === undefined) { const mid='m_'+Date.now(); list.push({id:Date.now().toString(), title:t, scope:d, badge:b, qbox:q, module:mid, category:'all'}); DATA.modules[mid]={id:mid, title:t, subtitle:d, backTo:`#/detail/${pid}`, tabs:[{id:'t1', label:'기본', questions:[]}]}; } else { list[idx].title=t; list[idx].scope=d; list[idx].badge=b; list[idx].qbox=q; } } else if (mode === 'module') { const list = DATA.modules[pid].tabs.find(x=>x.id===tabId).questions; if (idx === undefined) list.push({id:Date.now().toString(), text:t, src:d}); else { list[idx].text=t; list[idx].src=d; } } saveData(); closeModal(); renderRouter(); } function deleteCard(mode, pid, idx, tabId) { if(confirm('삭제하시겠습니까? (하위 데이터도 모두 삭제됩니다)')) { if (mode === 'subject') { const s = DATA.home.subjects[idx]; const d = DATA.details[s.id]; if(d) { d.cards.forEach(c => { if(c.module) delete DATA.modules[c.module]; }); delete DATA.details[s.id]; } DATA.home.subjects.splice(idx, 1); } else if (mode === 'detail') { const c = DATA.details[pid].cards[idx]; if(c.module) delete DATA.modules[c.module]; DATA.details[pid].cards.splice(idx, 1); } else if (mode === 'module') { DATA.modules[pid].tabs.find(x => x.id === tabId).questions.splice(idx, 1); } saveData(); renderRouter(); }} function deleteTab(mid, tid) { if(confirm('탭 삭제?')) { const t=DATA.modules[mid].tabs; if(t.length>1) t.splice(t.findIndex(x=>x.id===tid),1); saveData(); renderModule(mid); }} // [D-Day Logic] function openDDayModal() { const div = document.getElementById('ddayInputs'); div.innerHTML = ''; const ddays = DATA.home.dDays || []; for(let i=0; i<3; i++) { const d = ddays[i] || {label:'', date:''}; div.innerHTML += `
`; } document.getElementById('ddayModal').style.display='flex'; } function saveDDays() { const newDays = []; for(let i=0; i<3; i++) { const l = document.getElementById(`ddL_${i}`).value; const d = document.getElementById(`ddD_${i}`).value; if(l && d) newDays.push({label:l, date:d}); } DATA.home.dDays = newDays; saveData(); document.getElementById('ddayModal').style.display='none'; renderRouter(); } function copyLink(type, id, title, extra) { let link = ''; if (type === 'subject') link = `#/detail/${id}`; else if (type === 'detail') link = extra && extra.module ? `#/module/${extra.module}` : `#/detail/${extra.pid}?focus=${id}`; else if (type === 'module') link = `#/module/${extra.pid}?focus=${id}`; navigator.clipboard.writeText(`[${title}](${link})`).then(() => alert(`링크 복사됨:\n[${title}]`)); } function setupDragDrop(lvl, pid, tid) { const grid = document.getElementById(lvl==='home'?'homeGrid':(lvl==='detail'?'cardGrid':'questionGrid')); let srcIdx = null; grid.querySelectorAll('.card[draggable="true"]').forEach(el => { el.addEventListener('dragstart', e => { srcIdx=+el.dataset.index; el.classList.add('dragging'); }); el.addEventListener('dragend', e => el.classList.remove('dragging')); el.addEventListener('dragover', e => e.preventDefault()); el.addEventListener('drop', e => { e.stopPropagation(); const tgtIdx=+el.dataset.index; if(srcIdx!==tgtIdx && srcIdx!==null) { let list; if(lvl==='home') list=DATA.home.subjects; else if(lvl==='detail') list=DATA.details[pid].cards; else list=DATA.modules[pid].tabs.find(t=>t.id===tid).questions; const [m] = list.splice(srcIdx, 1); list.splice(tgtIdx, 0, m); saveData(); renderRouter(); } }); }); } function exportAnki(mid) { let csv = "Front,Back,Tags\n"; DATA.modules[mid].tabs.forEach(t => t.questions.forEach(q => csv += `"${q.text.replace(/"/g,'""')}","${(q.userNote||"").replace(/"/g,'""')}","${mid}"\n`)); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8;'})); a.download=`${mid}.csv`; a.click(); } function filterCards(k, btn) { document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); const sid = getRoute().id; DETAIL_BADGE_FILTER[sid] = k; const cards = DATA.details[sid].cards; cards.forEach((c, i) => { const el = document.getElementById(`card-${c.id||('detail-'+sid+'-'+i)}`); const tags = badgeTags(c.badge); if(el) el.style.display = (k==='all' || tags.includes(k)) ? 'flex' : 'none'; }); } function switchTab(mid, tid) { DATA.modules[mid].activeTab = tid; saveData(); renderModule(mid); } // [DUPLICATE & MOVE Implementation (Fixed V9.2)] function duplicateCard(level, pid, idx, tabId) { if (level === 'subject') { const org = DATA.home.subjects[idx]; const clone = deepCloneAndRegenerateIds(org, 'subject'); DATA.home.subjects.splice(idx + 1, 0, clone); } else if (level === 'detail') { const list = DATA.details[pid].cards; const clone = deepCloneAndRegenerateIds(list[idx], 'detail_card'); list.splice(idx + 1, 0, clone); } else if (level === 'module') { const list = DATA.modules[pid].tabs.find(t => t.id === tabId).questions; const clone = deepCloneAndRegenerateIds(list[idx], 'question'); list.splice(idx + 1, 0, clone); } saveData(); renderRouter(); } let moveTarget = null; // 이동 모달 열기 function moveCard(level, pid, idx, tabId, isMulti = false) { if(!isMulti) moveTarget = { level, pid, idx, tabId }; const selSubject = document.getElementById('moveSubject'); const grpTopic = document.getElementById('grpMoveTopic'); const grpTab = document.getElementById('grpMoveTab'); // Init UI selSubject.innerHTML = ''; document.getElementById('moveTopic').innerHTML = ''; document.getElementById('moveTab').innerHTML = ''; // 1. Populate Subjects (Always visible) DATA.home.subjects.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.innerText = s.name; selSubject.appendChild(opt); }); if (level === 'detail') { // 주제 카드 이동: 과목만 선택하면 됨 document.getElementById('moveModalTitle').innerText = "📂 주제 카드 이동 (과목 선택)"; grpTopic.style.display = 'none'; grpTab.style.display = 'none'; } else if (level === 'module') { // 기출문제 카드 이동: 과목 -> 주제 -> 탭 선택 필요 document.getElementById('moveModalTitle').innerText = "📂 문제 카드 이동 (위치 선택)"; grpTopic.style.display = 'block'; grpTab.style.display = 'block'; // Trigger cascades to populate Topic/Tab based on first subject updateMoveUI_Topics(); } document.getElementById('moveModal').style.display='flex'; } // 과목 선택 시 -> 주제 리스트 업데이트 function updateMoveUI_Topics() { if(moveTarget.level !== 'module') return; const subId = document.getElementById('moveSubject').value; const selTopic = document.getElementById('moveTopic'); selTopic.innerHTML = ''; const detail = DATA.details[subId]; if(detail && detail.cards.length > 0) { detail.cards.forEach((c, idx) => { // 모듈이 있어야 문제를 넣을 수 있음 if(c.module && DATA.modules[c.module]) { const opt = document.createElement('option'); opt.value = c.module; // Value is Module ID opt.innerText = c.title; selTopic.appendChild(opt); } }); } else { const opt = document.createElement('option'); opt.innerText = "(이동 가능한 주제 없음)"; selTopic.appendChild(opt); } updateMoveUI_Tabs(); } // 주제(모듈) 선택 시 -> 탭 리스트 업데이트 function updateMoveUI_Tabs() { if(moveTarget.level !== 'module') return; const mid = document.getElementById('moveTopic').value; const selTab = document.getElementById('moveTab'); selTab.innerHTML = ''; if(mid && DATA.modules[mid]) { DATA.modules[mid].tabs.forEach(t => { const opt = document.createElement('option'); opt.value = t.id; opt.innerText = t.label; selTab.appendChild(opt); }); } else { const opt = document.createElement('option'); opt.innerText = "(탭 없음)"; selTab.appendChild(opt); } } function execMoveCard() { const { level, pid, idx, tabId } = moveTarget; const movingIdx = (moveTarget.multi && moveTarget.multi.length) ? moveTarget.multi.slice().sort((a,b)=>a-b) : [idx]; const targetSubId = document.getElementById('moveSubject').value; if (level === 'detail') { // 주제 카드 이동 (다른 과목으로) if (pid === targetSubId) { alert("현재와 동일한 과목입니다."); return; } const cards = movingIdx.slice().reverse().map(i=>DATA.details[pid].cards.splice(i,1)[0]).reverse(); // Ensure target detail exists if(!DATA.details[targetSubId]) DATA.details[targetSubId] = {id:targetSubId, title:"상세", cards:[]}; // Push info cards.forEach(card=>{ DATA.details[targetSubId].cards.push(card); if(card.module && DATA.modules[card.module]) DATA.modules[card.module].backTo = `#/detail/${targetSubId}`; }); } else if (level === 'module') { // 문제 카드 이동 (과목 > 주제 > 탭) const targetMid = document.getElementById('moveTopic').value; const targetTid = document.getElementById('moveTab').value; if (!targetMid || !targetTid || !DATA.modules[targetMid]) { alert("이동할 대상(주제/탭)이 유효하지 않습니다."); return; } // Source List const srcList = DATA.modules[pid].tabs.find(t => t.id === tabId).questions; const moved = movingIdx.slice().reverse().map(i=>srcList.splice(i,1)[0]).reverse(); const dstList = DATA.modules[targetMid].tabs.find(t => t.id === targetTid).questions; moved.forEach(q=>dstList.push(q)); } saveData(); document.getElementById('moveModal').style.display='none'; renderRouter(); } init();