import { apiRequest } from './api.js'; import { mountAppBar } from './app-bar.js'; import { bindLogout, loadSession } from './auth.js'; import { searchRecipes } from './recipe-search.js'; import { bindScrollToTop } from './scroll-to-top.js'; const appBar = mountAppBar({ onManageAccess: openAccessManager }); const elements = { importForm: document.querySelector('#import-form'), importStatus: document.querySelector('#import-status'), importQueue: document.querySelector('#import-queue'), importJobList: document.querySelector('#import-job-list'), importJobCount: document.querySelector('#import-job-count'), processImportJobs: document.querySelector('#process-import-jobs'), recipeGrid: document.querySelector('#recipe-grid'), emptyLibrary: document.querySelector('#empty-library'), recipeCount: document.querySelector('#recipe-count'), recipeSearchInput: document.querySelector('#recipe-search-input'), clearRecipeSearch: document.querySelector('#clear-recipe-search'), tagFilter: document.querySelector('#tag-filter'), tagFilterOptions: document.querySelector('#tag-filter-options'), toggleTagFilter: document.querySelector('#toggle-tag-filter'), clearTagFilter: document.querySelector('#clear-tag-filter'), accessOverlay: document.querySelector('#access-overlay'), closeAccessOverlay: document.querySelector('#close-access-overlay'), accessOverlayBackdrop: document.querySelector('#access-overlay-backdrop'), accessForm: document.querySelector('#access-form'), accessEmail: document.querySelector('#access-email'), allowedEmailList: document.querySelector('#allowed-email-list'), scrollToTop: document.querySelector('#scroll-to-top'), toast: document.querySelector('#toast'), }; let importJobs = []; let importJobPollTimer = null; let savedRecipes = []; let currentUser = null; let searchQuery = ''; let searchTimer = null; let searchIsComposing = false; let showAllTags = false; const selectedTags = new Set(); const SEARCH_DEBOUNCE_MS = 180; const VISIBLE_TAG_LIMIT = 6; const IMPORT_JOB_POLL_MS = 3000; function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function safeImageUrl(value) { if (!value) return ''; try { const url = new URL(value, window.location.origin); if (url.protocol === 'http:' || url.protocol === 'https:') return url.href; } catch { return ''; } return ''; } function showToast(message) { elements.toast.textContent = message; elements.toast.hidden = false; window.setTimeout(() => { elements.toast.hidden = true; }, 3200); } function setStatus(message, kind = '') { elements.importStatus.textContent = message; elements.importStatus.className = `status ${kind}`.trim(); elements.importStatus.hidden = false; } function recipeImage(recipe) { return recipe.imagePath ? `/media/${recipe.imagePath}` : ''; } function normalizeTag(value) { return String(value ?? '').trim().replace(/^#+\s*/, '').replace(/\s+/g, ' '); } function tagKey(value) { return normalizeTag(value).toLocaleLowerCase(); } function tagsForRecipe(recipe) { const tags = new Map(); for (const value of recipe.tags ?? []) { const tag = normalizeTag(value); if (tag) tags.set(tagKey(tag), tag); } return tags; } function availableTags() { const tags = new Map(); for (const recipe of savedRecipes) { for (const [key, label] of tagsForRecipe(recipe)) { const existing = tags.get(key); tags.set(key, { label: existing?.label ?? label, count: (existing?.count ?? 0) + 1 }); } } return [...tags.entries()].sort(([, a], [, b]) => ( b.count - a.count || a.label.localeCompare(b.label, 'ko') )); } function renderTagFilter() { const tags = availableTags(); const selectedExtras = tags .slice(VISIBLE_TAG_LIMIT) .filter(([key]) => selectedTags.has(key)); const visibleTags = showAllTags ? tags : [...tags.slice(0, VISIBLE_TAG_LIMIT), ...selectedExtras]; const hiddenTagCount = tags.length - visibleTags.length; elements.tagFilter.hidden = tags.length === 0; elements.clearTagFilter.hidden = selectedTags.size === 0; elements.toggleTagFilter.hidden = showAllTags ? tags.length <= VISIBLE_TAG_LIMIT : hiddenTagCount === 0; elements.toggleTagFilter.textContent = showAllTags ? '접기' : `+ ${hiddenTagCount}개 더보기`; elements.toggleTagFilter.setAttribute('aria-expanded', String(showAllTags)); elements.tagFilterOptions.innerHTML = visibleTags.map(([key, { label, count }]) => ` `).join(''); } function renderRecipes() { const tagFilteredRecipes = selectedTags.size === 0 ? savedRecipes : savedRecipes.filter((recipe) => { const recipeTags = tagsForRecipe(recipe); return [...selectedTags].every((tag) => recipeTags.has(tag)); }); const matches = searchRecipes(tagFilteredRecipes, searchQuery); const hasFilters = selectedTags.size > 0 || searchQuery.length > 0; elements.recipeCount.textContent = hasFilters ? `${matches.length} / ${savedRecipes.length} RECIPES` : `${matches.length} RECIPES`; elements.emptyLibrary.hidden = matches.length !== 0; elements.emptyLibrary.innerHTML = savedRecipes.length === 0 ? '아직 저장한 레시피가 없습니다.
첫 링크를 가져와 보세요.' : '검색 조건에 맞는 레시피가 없습니다.'; elements.recipeGrid.innerHTML = matches.map(({ recipe, matchedIngredients }) => { const image = safeImageUrl(recipeImage(recipe)); const tags = [...tagsForRecipe(recipe).values()]; return `
${image ? `` : 'NO IMAGE'}
${escapeHtml(recipe.source.platform)}

${escapeHtml(recipe.title)}

${escapeHtml(recipe.summary || recipe.source.author || '')}

${matchedIngredients.length ? `

일치 재료: ${matchedIngredients.map(escapeHtml).join(', ')}

` : ''} ${tags.length ? `
${tags.map((tag) => `#${escapeHtml(tag)}`).join('')}
` : ''}
`; }).join(''); } async function loadRecipes() { const { recipes } = await apiRequest('/api/recipes'); savedRecipes = recipes; const currentTagKeys = new Set(availableTags().map(([key]) => key)); for (const tag of selectedTags) { if (!currentTagKeys.has(tag)) selectedTags.delete(tag); } renderTagFilter(); renderRecipes(); } function importJobLabel(status) { if (status === 'pending') return '대기 중'; if (status === 'processing') return '처리 중'; return '처리 실패'; } function importJobUrlLabel(value) { try { const url = new URL(value); return `${url.hostname}${url.pathname}${url.search}`; } catch { return value; } } function renderImportJobs() { const pendingCount = importJobs.filter(({ status }) => status === 'pending').length; const processingCount = importJobs.filter(({ status }) => status === 'processing').length; elements.importQueue.hidden = importJobs.length === 0; elements.importJobCount.textContent = `${importJobs.length}개`; elements.processImportJobs.disabled = pendingCount === 0 && processingCount === 0; elements.processImportJobs.textContent = processingCount > 0 ? `처리 재요청 (${processingCount})` : `처리 요청${pendingCount ? ` (${pendingCount})` : ''}`; elements.importJobList.innerHTML = importJobs.map((job) => `
${importJobLabel(job.status)} ${escapeHtml(importJobUrlLabel(job.url))} ${job.error?.message ? `${escapeHtml(job.error.message)}` : ''}
${job.status === 'failed' ? `` : ''}
`).join(''); } function scheduleImportJobPoll() { window.clearTimeout(importJobPollTimer); importJobPollTimer = null; if (!importJobs.some(({ status }) => status === 'processing')) return; importJobPollTimer = window.setTimeout(async () => { try { await loadImportJobs(); } catch (error) { showToast(error.message); scheduleImportJobPoll(); } }, IMPORT_JOB_POLL_MS); } async function loadImportJobs() { const previousActiveIds = new Set( importJobs .filter(({ status }) => status === 'pending' || status === 'processing') .map(({ id }) => id), ); const { jobs } = await apiRequest('/api/import/jobs'); importJobs = jobs; renderImportJobs(); const visibleIds = new Set(jobs.map(({ id }) => id)); if ([...previousActiveIds].some((id) => !visibleIds.has(id))) { await loadRecipes(); } scheduleImportJobPoll(); } async function loadAllowedEmails() { const { emails } = await apiRequest('/api/allowed-emails'); elements.allowedEmailList.innerHTML = emails.map((email) => { const isCurrent = email === currentUser.email.toLowerCase(); return `
${escapeHtml(email)} ${isCurrent ? '현재 관리자' : ``}
`; }).join(''); } function updatePageLock() { document.body.classList.toggle('drawer-open', !elements.accessOverlay.hidden); } function setAccessOverlayOpen(open) { elements.accessOverlay.setAttribute('aria-hidden', String(!open)); elements.accessOverlay.hidden = !open; elements.accessOverlayBackdrop.hidden = !open; updatePageLock(); if (open) elements.accessEmail.focus(); else appBar.focusMenuButton(); } async function openAccessManager() { setAccessOverlayOpen(true); try { await loadAllowedEmails(); } catch (error) { showToast(error.message); } } elements.importForm.addEventListener('submit', async (event) => { event.preventDefault(); const button = elements.importForm.querySelector('button'); button.disabled = true; setStatus('URL을 대기열에 추가하는 중...'); try { const urls = String(new FormData(elements.importForm).get('urls') ?? '') .split(/\s+/) .map((url) => url.trim()) .filter(Boolean); const result = await apiRequest('/api/import/jobs', { method: 'POST', body: JSON.stringify({ urls }), }); elements.importForm.reset(); const duplicateMessage = result.duplicateCount ? `, 중복 ${result.duplicateCount}개 제외` : ''; setStatus(`${result.addedCount}개를 대기열에 추가했습니다${duplicateMessage}.`, 'success'); importJobs = result.jobs; renderImportJobs(); scheduleImportJobPoll(); } catch (error) { setStatus(error.message, 'error'); } finally { button.disabled = false; } }); elements.processImportJobs.addEventListener('click', async () => { elements.processImportJobs.disabled = true; try { await apiRequest('/api/import/jobs/process', { method: 'POST' }); await loadImportJobs(); } catch (error) { showToast(error.message); renderImportJobs(); } }); elements.importJobList.addEventListener('click', async (event) => { const button = event.target.closest('[data-retry-import-job]'); if (!button) return; button.disabled = true; try { await apiRequest(`/api/import/jobs/${encodeURIComponent(button.dataset.retryImportJob)}/retry`, { method: 'POST', }); await loadImportJobs(); } catch (error) { showToast(error.message); button.disabled = false; } }); elements.tagFilterOptions.addEventListener('click', (event) => { const button = event.target.closest('[data-tag]'); if (!button) return; const key = button.dataset.tag; if (selectedTags.has(key)) selectedTags.delete(key); else selectedTags.add(key); renderTagFilter(); renderRecipes(); }); elements.clearTagFilter.addEventListener('click', () => { selectedTags.clear(); showAllTags = false; renderTagFilter(); renderRecipes(); }); elements.toggleTagFilter.addEventListener('click', () => { showAllTags = !showAllTags; renderTagFilter(); }); function scheduleRecipeSearch() { const nextQuery = elements.recipeSearchInput.value.trim(); elements.clearRecipeSearch.hidden = nextQuery.length === 0; window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { searchQuery = nextQuery; searchTimer = null; renderRecipes(); }, SEARCH_DEBOUNCE_MS); } elements.recipeSearchInput.addEventListener('compositionstart', () => { searchIsComposing = true; }); elements.recipeSearchInput.addEventListener('compositionend', () => { searchIsComposing = false; scheduleRecipeSearch(); }); elements.recipeSearchInput.addEventListener('input', (event) => { elements.clearRecipeSearch.hidden = elements.recipeSearchInput.value.trim().length === 0; if (searchIsComposing || event.isComposing) return; scheduleRecipeSearch(); }); elements.clearRecipeSearch.addEventListener('click', () => { window.clearTimeout(searchTimer); searchTimer = null; elements.recipeSearchInput.value = ''; searchQuery = ''; elements.clearRecipeSearch.hidden = true; elements.recipeSearchInput.focus(); renderRecipes(); }); elements.closeAccessOverlay.addEventListener('click', () => setAccessOverlayOpen(false)); elements.accessOverlayBackdrop.addEventListener('click', () => setAccessOverlayOpen(false)); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (!elements.accessOverlay.hidden) setAccessOverlayOpen(false); }); elements.accessForm.addEventListener('submit', async (event) => { event.preventDefault(); const button = elements.accessForm.querySelector('button'); button.disabled = true; try { const email = new FormData(elements.accessForm).get('email'); await apiRequest('/api/allowed-emails', { method: 'POST', body: JSON.stringify({ email }), }); elements.accessForm.reset(); showToast(`${email} 계정을 추가했습니다.`); await loadAllowedEmails(); } catch (error) { showToast(error.message); } finally { button.disabled = false; } }); elements.allowedEmailList.addEventListener('click', async (event) => { const button = event.target.closest('[data-remove-email]'); if (!button) return; button.disabled = true; try { await apiRequest(`/api/allowed-emails/${encodeURIComponent(button.dataset.removeEmail)}`, { method: 'DELETE', }); showToast(`${button.dataset.removeEmail} 계정을 삭제했습니다.`); await loadAllowedEmails(); } catch (error) { showToast(error.message); button.disabled = false; } }); bindLogout(); bindScrollToTop(elements.scrollToTop); currentUser = await loadSession(); appBar.setUser(currentUser); await loadRecipes(); await loadImportJobs(); if ( currentUser.canManageAccess && new URLSearchParams(window.location.search).get('manage-access') === '1' ) { await openAccessManager(); }