first commit
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user