first commit

This commit is contained in:
2026-08-13 18:36:31 +09:00
commit cee36bf12d
48 changed files with 4212 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
export async function apiRequest(path, options = {}) {
const response = await fetch(path, {
...options,
credentials: 'same-origin',
headers: {
...(options.body ? { 'content-type': 'application/json' } : {}),
...options.headers,
},
});
if (response.status === 401) {
window.location.assign('/login');
throw new Error('로그인이 필요합니다.');
}
if (response.status === 204) return null;
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error?.message ?? '요청을 처리하지 못했습니다.');
return data;
}
+515
View File
@@ -0,0 +1,515 @@
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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.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 `<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.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;
}
+24
View File
@@ -0,0 +1,24 @@
import { apiRequest } from './api.js';
export async function loadSession() {
const { user } = await apiRequest('/auth/me');
const name = document.querySelector('#profile-name');
if (name) name.textContent = user.name || user.email;
const image = document.querySelector('#profile-image');
if (image && user.picture) {
image.src = user.picture;
image.alt = `${user.name || user.email} 프로필`;
image.hidden = false;
}
return user;
}
export function bindLogout() {
document.querySelectorAll('[data-logout]').forEach((button) => {
button.addEventListener('click', async () => {
await apiRequest('/auth/logout', { method: 'POST' });
window.location.assign('/login');
});
});
}
+49
View File
@@ -0,0 +1,49 @@
function normalizeSearchText(value) {
return String(value ?? '').trim().toLocaleLowerCase().replace(/\s+/g, ' ');
}
function ingredientNames(recipe) {
return (recipe.ingredientGroups ?? [])
.flatMap((group) => group.items ?? [])
.map((item) => item.name)
.filter(Boolean);
}
export function rankRecipeMatch(recipe, query) {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return { score: 0, matchedIngredients: [] };
const ingredients = ingredientNames(recipe);
const matchedIngredients = ingredients.filter((name) => (
normalizeSearchText(name).includes(normalizedQuery)
));
const hasExactIngredient = matchedIngredients.some((name) => (
normalizeSearchText(name) === normalizedQuery
));
const title = normalizeSearchText(recipe.title);
const tags = (recipe.tags ?? []).map(normalizeSearchText);
const summary = normalizeSearchText(recipe.summary);
let score = 0;
if (hasExactIngredient) score = 500;
else if (matchedIngredients.length > 0) score = 400;
else if (title === normalizedQuery) score = 350;
else if (title.includes(normalizedQuery)) score = 300;
else if (tags.includes(normalizedQuery)) score = 250;
else if (tags.some((tag) => tag.includes(normalizedQuery))) score = 200;
else if (summary.includes(normalizedQuery)) score = 100;
return { score, matchedIngredients };
}
export function searchRecipes(recipes, query) {
if (!normalizeSearchText(query)) {
return recipes.map((recipe) => ({ recipe, score: 0, matchedIngredients: [] }));
}
return recipes
.map((recipe, index) => ({ recipe, index, ...rankRecipeMatch(recipe, query) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score || a.index - b.index)
.map(({ recipe, score, matchedIngredients }) => ({ recipe, score, matchedIngredients }));
}
+233
View File
@@ -0,0 +1,233 @@
import { apiRequest } from './api.js';
import { bindLogout, loadSession } from './auth.js';
import { bindScrollToTop } from './scroll-to-top.js';
const detail = document.querySelector('#recipe-detail');
const toast = document.querySelector('#toast');
const scrollToTop = document.querySelector('#scroll-to-top');
let currentRecipe = null;
let toastTimer;
bindScrollToTop(scrollToTop);
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function youtubeTimestampUrl(sourceUrl, seconds) {
try {
const url = new URL(sourceUrl);
url.searchParams.set('t', `${Math.round(seconds)}s`);
return url.href;
} catch {
return sourceUrl;
}
}
function showToast(message) {
window.clearTimeout(toastTimer);
toast.textContent = message;
toast.hidden = false;
toastTimer = window.setTimeout(() => { toast.hidden = true; }, 3200);
}
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 readRecipeForm(form) {
const ingredientGroups = [...form.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 = [...form.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: form.querySelector('#recipe-title').value.trim(),
summary: form.querySelector('#recipe-summary').value.trim() || null,
servings: form.querySelector('#recipe-servings').value.trim() || null,
ingredientGroups,
steps,
tips: form.querySelector('#recipe-tips').value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
tags: form.querySelector('#recipe-tags').value.split(',').map((item) => item.trim()).filter(Boolean),
};
}
function renderRecipeEditor(recipe) {
const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null;
detail.innerHTML = `
<a class="back-link" href="/">← Recipe Library</a>
<form id="recipe-edit-form" class="preview-panel recipe-edit-panel">
<div class="section-heading">
<div><p class="section-number">EDIT RECIPE</p><h2>레시피 수정</h2></div>
<button id="cancel-edit" class="button button-quiet" type="button">취소</button>
</div>
<div class="preview-layout">
<div class="preview-image">
${image ? `<img src="${escapeHtml(image)}" alt="">` : '<span>NO IMAGE</span>'}
<span class="platform-label">${escapeHtml(recipe.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>${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>${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>태그 (쉼표로 구분, 기존 표현 재사용 권장)<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>
</form>`;
const form = document.querySelector('#recipe-edit-form');
document.querySelector('#cancel-edit').addEventListener('click', () => renderRecipe(currentRecipe));
form.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button) return;
const draft = { ...recipe, ...readRecipeForm(form) };
const groupIndex = Number(button.dataset.group);
const itemIndex = Number(button.dataset.item);
const stepIndex = Number(button.dataset.stepIndex);
if (button.dataset.action === 'add-group') draft.ingredientGroups.push({ name: '재료', items: [] });
if (button.dataset.action === 'remove-group') draft.ingredientGroups.splice(groupIndex, 1);
if (button.dataset.action === 'add-item') draft.ingredientGroups[groupIndex].items.push({ name: '', amount: null });
if (button.dataset.action === 'remove-item') draft.ingredientGroups[groupIndex].items.splice(itemIndex, 1);
if (button.dataset.action === 'add-step') draft.steps.push({ order: draft.steps.length + 1, text: '', timestampSec: null });
if (button.dataset.action === 'remove-step') draft.steps.splice(stepIndex, 1);
draft.steps.forEach((step, index) => { step.order = index + 1; });
renderRecipeEditor(draft);
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('[type="submit"]');
button.disabled = true;
try {
const { recipe: updatedRecipe } = await apiRequest(`/api/recipes/${encodeURIComponent(recipe._id)}`, {
method: 'PATCH',
body: JSON.stringify(readRecipeForm(form)),
});
currentRecipe = updatedRecipe;
renderRecipe(currentRecipe);
showToast('레시피를 수정했습니다.');
} catch (error) {
showToast(error.message);
button.disabled = false;
}
});
}
function renderRecipe(recipe) {
document.title = `${recipe.title} · Our Recipe Atlas`;
const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null;
detail.innerHTML = `
<a class="back-link" href="/">← Recipe Library</a>
<article class="recipe-detail">
<div class="detail-hero">
<div class="detail-image">${image ? `<img src="${escapeHtml(image)}" alt="">` : '<span>NO IMAGE</span>'}</div>
<div class="detail-intro">
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
<h1>${escapeHtml(recipe.title)}</h1>
${recipe.summary ? `<p>${escapeHtml(recipe.summary)}</p>` : ''}
${recipe.servings ? `<p class="servings">분량 · ${escapeHtml(recipe.servings)}</p>` : ''}
<div class="detail-actions">
<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button>
<a class="button button-secondary" href="${escapeHtml(recipe.source.url)}" target="_blank" rel="noreferrer">원본 보기 ↗</a>
</div>
</div>
</div>
<div class="detail-columns">
<section>
<p class="section-number">INGREDIENTS</p>
<h2>재료</h2>
${recipe.ingredientGroups.map((group) => `<div class="ingredient-detail">
<h3>${escapeHtml(group.name)}</h3>
<ul>${group.items.map((item) => `<li><span>${escapeHtml(item.name)}</span><strong>${escapeHtml(item.amount || '')}</strong></li>`).join('')}</ul>
</div>`).join('')}
</section>
<section>
<p class="section-number">METHOD</p>
<h2>조리 순서</h2>
<ol class="method-list">${recipe.steps.map((step) => {
const body = `<span>${escapeHtml(step.text)}</span>`;
return `<li><strong>${String(step.order).padStart(2, '0')}</strong>${step.timestampSec != null && recipe.source.platform === 'youtube'
? `<a href="${escapeHtml(youtubeTimestampUrl(recipe.source.url, step.timestampSec))}" target="_blank" rel="noreferrer">${body}<small>${Math.floor(step.timestampSec / 60)}:${String(Math.round(step.timestampSec) % 60).padStart(2, '0')} ↗</small></a>`
: body}</li>`;
}).join('')}</ol>
${recipe.tips.length ? `<div class="tips"><h3>Tips</h3><ul>${recipe.tips.map((tip) => `<li>${escapeHtml(tip)}</li>`).join('')}</ul></div>` : ''}
</section>
</div>
<footer class="detail-footer">
<div>${recipe.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>
<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>
</footer>
</article>`;
document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe));
document.querySelector('#delete-recipe').addEventListener('click', async () => {
if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return;
try {
await apiRequest(`/api/recipes/${encodeURIComponent(recipe._id)}`, { method: 'DELETE' });
window.location.assign('/');
} catch (error) {
showToast(error.message);
}
});
}
bindLogout();
await loadSession();
try {
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
const { recipe } = await apiRequest(`/api/recipes/${encodeURIComponent(recipeId)}`);
currentRecipe = recipe;
renderRecipe(currentRecipe);
} catch (error) {
detail.innerHTML = `<p class="empty-state">${escapeHtml(error.message)}</p><a class="button button-secondary" href="/">목록으로 돌아가기</a>`;
}
+14
View File
@@ -0,0 +1,14 @@
export function bindScrollToTop(button) {
if (!button) return;
function updateVisibility() {
button.hidden = window.scrollY <= 480;
}
window.addEventListener('scroll', updateVisibility, { passive: true });
button.addEventListener('click', () => {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
});
updateVisibility();
}