import { apiRequest } from './api.js';
import { bindLogout, loadSession } from './auth.js';
import { searchRecipes } from './recipe-search.js';
import { bindScrollToTop } from './scroll-to-top.js';
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'),
accessMenuButton: document.querySelector('#access-menu-button'),
accessDrawer: document.querySelector('#access-drawer'),
closeAccessDrawer: document.querySelector('#close-access-drawer'),
drawerBackdrop: document.querySelector('#drawer-backdrop'),
drawerUserName: document.querySelector('#drawer-user-name'),
drawerUserEmail: document.querySelector('#drawer-user-email'),
openAccessOverlay: document.querySelector('#open-access-overlay'),
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.accessDrawer.hidden || !elements.accessOverlay.hidden,
);
}
function setAccessDrawerOpen(open) {
elements.accessMenuButton.setAttribute('aria-expanded', String(open));
elements.accessDrawer.setAttribute('aria-hidden', String(!open));
elements.accessDrawer.hidden = !open;
elements.drawerBackdrop.hidden = !open;
updatePageLock();
if (open) elements.closeAccessDrawer.focus();
else elements.accessMenuButton.focus();
}
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 elements.accessMenuButton.focus();
}
function ingredientGroupTemplate(group, groupIndex) {
return ``;
}
function renderPreview() {
const { recipe, imagePreviewUrl, source } = previewData;
const image = safeImageUrl(imagePreviewUrl);
elements.previewForm.innerHTML = `
재료
${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.accessMenuButton.addEventListener('click', () => {
setAccessDrawerOpen(true);
});
elements.openAccessOverlay.addEventListener('click', async () => {
setAccessDrawerOpen(false);
setAccessOverlayOpen(true);
try {
await loadAllowedEmails();
} catch (error) {
showToast(error.message);
}
});
elements.closeAccessDrawer.addEventListener('click', () => setAccessDrawerOpen(false));
elements.drawerBackdrop.addEventListener('click', () => setAccessDrawerOpen(false));
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);
else if (!elements.accessDrawer.hidden) setAccessDrawerOpen(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();
elements.drawerUserName.textContent = currentUser.name || currentUser.email;
elements.drawerUserEmail.textContent = currentUser.email;
await loadRecipes();
if (currentUser.canManageAccess) {
elements.openAccessOverlay.hidden = false;
}