Files

240 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiRequest } from './api.js';
import { mountAppBar } from './app-bar.js';
import { bindLogout, loadSession } from './auth.js';
import { bindScrollToTop } from './scroll-to-top.js';
const appBar = mountAppBar();
const detail = document.querySelector('#recipe-detail');
const toast = document.querySelector('#toast');
const scrollToTop = document.querySelector('#scroll-to-top');
let currentRecipe = null;
let currentUser = null;
let toastTimer;
bindScrollToTop(scrollToTop);
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.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;
const canEdit = recipe.ownerGoogleSub === currentUser.googleSub;
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">
${canEdit ? '<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>
${canEdit ? '<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>' : ''}
</footer>
</article>`;
if (!canEdit) return;
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();
currentUser = await loadSession();
appBar.setUser(currentUser);
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>`;
}