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'), previewSection: document.querySelector('#preview-section'), previewForm: document.querySelector('#preview-form'), 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 previewData = 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; 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(); } 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); } } function ingredientGroupTemplate(group, groupIndex) { return `
${group.items.map((item, itemIndex) => `
`).join('')}
`; } function renderPreview() { const { recipe, imagePreviewUrl, source } = previewData; const image = safeImageUrl(imagePreviewUrl); elements.previewForm.innerHTML = `
${image ? `가져온 레시피 미리보기` : 'NO IMAGE'} ${escapeHtml(source.platform)}

재료

${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}

조리 순서

${recipe.steps.map((step, index) => `
${index + 1}
`).join('')}
`; elements.previewSection.hidden = false; elements.previewSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function readPreviewForm() { const ingredientGroups = [...elements.previewForm.querySelectorAll('[data-ingredient-group]')].map((group) => ({ name: group.querySelector('[data-group-name]').value.trim(), items: [...group.querySelectorAll('[data-ingredient-item]')].map((item) => ({ name: item.querySelector('[data-item-name]').value.trim(), amount: item.querySelector('[data-item-amount]').value.trim() || null, })), })); const steps = [...elements.previewForm.querySelectorAll('[data-step]')].map((step, index) => { const timestamp = step.querySelector('[data-step-time]').value; return { order: index + 1, text: step.querySelector('[data-step-text]').value.trim(), timestampSec: timestamp === '' ? null : Number(timestamp), }; }); return { title: document.querySelector('#recipe-title').value.trim(), summary: document.querySelector('#recipe-summary').value.trim() || null, servings: document.querySelector('#recipe-servings').value.trim() || null, ingredientGroups, steps, tips: document.querySelector('#recipe-tips').value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean), tags: document.querySelector('#recipe-tags').value.split(',').map((item) => item.trim()).filter(Boolean), }; } function mutatePreview(action, button) { previewData.recipe = readPreviewForm(); const groupIndex = Number(button.dataset.group); const itemIndex = Number(button.dataset.item); const stepIndex = Number(button.dataset.stepIndex); if (action === 'add-group') previewData.recipe.ingredientGroups.push({ name: '재료', items: [] }); if (action === 'remove-group') previewData.recipe.ingredientGroups.splice(groupIndex, 1); if (action === 'add-item') previewData.recipe.ingredientGroups[groupIndex].items.push({ name: '', amount: null }); if (action === 'remove-item') previewData.recipe.ingredientGroups[groupIndex].items.splice(itemIndex, 1); if (action === 'add-step') previewData.recipe.steps.push({ order: previewData.recipe.steps.length + 1, text: '', timestampSec: null }); if (action === 'remove-step') previewData.recipe.steps.splice(stepIndex, 1); previewData.recipe.steps.forEach((step, index) => { step.order = index + 1; }); renderPreview(); } elements.importForm.addEventListener('submit', async (event) => { event.preventDefault(); const button = elements.importForm.querySelector('button'); button.disabled = true; const timers = [ window.setTimeout(() => setStatus('콘텐츠 가져오는 중...'), 500), window.setTimeout(() => setStatus('레시피 분석 중...'), 1600), ]; setStatus('링크 확인 중...'); try { const url = new FormData(elements.importForm).get('url'); previewData = await apiRequest('/api/import/preview', { method: 'POST', body: JSON.stringify({ url }), }); timers.forEach(window.clearTimeout); setStatus('분석 완료', 'success'); renderPreview(); } catch (error) { timers.forEach(window.clearTimeout); setStatus(error.message, 'error'); } finally { button.disabled = false; } }); elements.previewForm.addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (button) mutatePreview(button.dataset.action, button); }); elements.previewForm.addEventListener('submit', async (event) => { event.preventDefault(); const button = elements.previewForm.querySelector('[type="submit"]'); button.disabled = true; try { const { recipe } = await apiRequest('/api/recipes', { method: 'POST', body: JSON.stringify({ recipe: readPreviewForm(), source: previewData.source, imagePreviewUrl: previewData.imagePreviewUrl, }), }); previewData = null; elements.previewSection.hidden = true; elements.importForm.reset(); showToast(`${recipe.title} 레시피를 저장했습니다.`); await loadRecipes(); } catch (error) { showToast(error.message); } finally { button.disabled = false; } }); document.querySelector('#cancel-preview').addEventListener('click', () => { previewData = null; elements.previewSection.hidden = true; }); 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(); if ( currentUser.canManageAccess && new URLSearchParams(window.location.search).get('manage-access') === '1' ) { await openAccessManager(); }