493 lines
19 KiB
JavaScript
493 lines
19 KiB
JavaScript
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 }]) => `
|
||
<button class="tag-filter-button" type="button" data-tag="${escapeHtml(key)}"
|
||
aria-pressed="${selectedTags.has(key)}">#${escapeHtml(label)} <span>${count}</span></button>
|
||
`).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
|
||
? '아직 저장한 레시피가 없습니다.<br>첫 링크를 가져와 보세요.'
|
||
: '검색 조건에 맞는 레시피가 없습니다.';
|
||
elements.recipeGrid.innerHTML = matches.map(({ recipe, matchedIngredients }) => {
|
||
const image = safeImageUrl(recipeImage(recipe));
|
||
const tags = [...tagsForRecipe(recipe).values()];
|
||
return `<a class="recipe-card" href="/recipe/${encodeURIComponent(recipe._id)}">
|
||
<div class="recipe-card-image">
|
||
${image ? `<img src="${escapeHtml(image)}" alt="" loading="lazy">` : '<span>NO IMAGE</span>'}
|
||
</div>
|
||
<div class="recipe-card-body">
|
||
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||
<h3>${escapeHtml(recipe.title)}</h3>
|
||
<p>${escapeHtml(recipe.summary || recipe.source.author || '')}</p>
|
||
${matchedIngredients.length
|
||
? `<p class="recipe-card-match">일치 재료: ${matchedIngredients.map(escapeHtml).join(', ')}</p>`
|
||
: ''}
|
||
${tags.length ? `<div class="recipe-card-tags">${tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>` : ''}
|
||
</div>
|
||
</a>`;
|
||
}).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 `<div class="allowed-email-row">
|
||
<span>${escapeHtml(email)}</span>
|
||
${isCurrent
|
||
? '<small>현재 관리자</small>'
|
||
: `<button class="text-button danger" type="button" data-remove-email="${escapeHtml(email)}">삭제</button>`}
|
||
</div>`;
|
||
}).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 `<fieldset class="editor-group" data-ingredient-group>
|
||
<div class="editor-group-heading">
|
||
<input aria-label="재료 그룹 이름" data-group-name value="${escapeHtml(group.name)}" required>
|
||
<button class="text-button danger" type="button" data-action="remove-group" data-group="${groupIndex}">그룹 삭제</button>
|
||
</div>
|
||
<div class="ingredient-list">
|
||
${group.items.map((item, itemIndex) => `<div class="ingredient-row" data-ingredient-item>
|
||
<input aria-label="재료 이름" data-item-name value="${escapeHtml(item.name)}" placeholder="재료" required>
|
||
<input aria-label="재료 수량" data-item-amount value="${escapeHtml(item.amount || '')}" placeholder="수량">
|
||
<button class="icon-button" type="button" aria-label="재료 삭제" data-action="remove-item" data-group="${groupIndex}" data-item="${itemIndex}">×</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
<button class="text-button" type="button" data-action="add-item" data-group="${groupIndex}">+ 재료 추가</button>
|
||
</fieldset>`;
|
||
}
|
||
|
||
function renderPreview() {
|
||
const { recipe, imagePreviewUrl, source } = previewData;
|
||
const image = safeImageUrl(imagePreviewUrl);
|
||
elements.previewForm.innerHTML = `
|
||
<div class="preview-layout">
|
||
<div class="preview-image">
|
||
${image ? `<img src="${escapeHtml(image)}" alt="가져온 레시피 미리보기">` : '<span>NO IMAGE</span>'}
|
||
<span class="platform-label">${escapeHtml(source.platform)}</span>
|
||
</div>
|
||
<div class="editor-fields">
|
||
<label>제목<input id="recipe-title" value="${escapeHtml(recipe.title)}" required></label>
|
||
<label>한 줄 설명<textarea id="recipe-summary" rows="2">${escapeHtml(recipe.summary || '')}</textarea></label>
|
||
<label>분량<input id="recipe-servings" value="${escapeHtml(recipe.servings || '')}"></label>
|
||
</div>
|
||
</div>
|
||
<div class="editor-section">
|
||
<div class="editor-section-heading"><h3>재료</h3><button class="text-button" type="button" data-action="add-group">+ 그룹 추가</button></div>
|
||
<div id="ingredient-groups">${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}</div>
|
||
</div>
|
||
<div class="editor-section">
|
||
<div class="editor-section-heading"><h3>조리 순서</h3><button class="text-button" type="button" data-action="add-step">+ 단계 추가</button></div>
|
||
<div id="step-list">${recipe.steps.map((step, index) => `<div class="step-row" data-step>
|
||
<span>${index + 1}</span>
|
||
<textarea data-step-text rows="2" required>${escapeHtml(step.text)}</textarea>
|
||
<input data-step-time type="number" min="0" step="1" value="${step.timestampSec ?? ''}" placeholder="초">
|
||
<button class="icon-button" type="button" aria-label="단계 삭제" data-action="remove-step" data-step-index="${index}">×</button>
|
||
</div>`).join('')}</div>
|
||
</div>
|
||
<div class="editor-fields two-column">
|
||
<label>팁 (한 줄에 하나)<textarea id="recipe-tips" rows="3">${escapeHtml(recipe.tips.join('\n'))}</textarea></label>
|
||
<label>태그 (표준 태그 중 최대 3개, 쉼표로 구분)<textarea id="recipe-tags" rows="3">${escapeHtml(recipe.tags.join(', '))}</textarea></label>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button class="button button-primary" type="submit">아틀라스에 저장</button>
|
||
</div>`;
|
||
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();
|
||
}
|