feat: add batched recipe import queue

This commit is contained in:
2026-08-28 11:09:36 +09:00
parent 4491c91455
commit f65e660184
19 changed files with 1080 additions and 262 deletions
+14 -3
View File
@@ -135,11 +135,22 @@ img { display: block; max-width: 100%; }
.import-panel { display: grid; grid-template-columns: minmax(180px, 0.7fr) 2fr; gap: 30px 54px; align-items: center; }
.import-panel h2, .preview-panel h2, .library h2 { margin: 4px 0 0; font-family: var(--serif); font-size: clamp(2rem, 4vw, 3.4rem); font-weight: 400; }
.import-form { display: flex; gap: 10px; padding: 8px; border-radius: 999px; background: var(--white); }
.import-form input { flex: 1; min-width: 0; padding: 10px 18px; border: 0; outline: 0; color: var(--ink); background: transparent; }
.import-form { display: flex; gap: 10px; align-items: flex-end; padding: 8px; border-radius: 18px; background: var(--white); }
.import-form textarea { flex: 1; min-width: 0; min-height: 112px; padding: 12px 14px; resize: vertical; border: 0; outline: 0; color: var(--ink); background: transparent; font: inherit; line-height: 1.5; }
.status { grid-column: 2; padding: 12px 18px; border-radius: 12px; background: rgba(255, 255, 255, 0.1); }
.status.success { color: #d8efc9; }
.status.error { color: #ffd0c4; }
.import-queue { grid-column: 2; padding: 18px; border-radius: 18px; color: var(--ink); background: var(--white); }
.import-queue-heading, .import-queue-heading > div, .import-job { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.import-queue-heading h3 { margin: 0; font-family: var(--serif); font-size: 1.45rem; font-weight: 400; }
.import-job-list { display: grid; gap: 8px; margin-top: 14px; }
.import-job { padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; }
.import-job-body { display: grid; min-width: 0; gap: 4px; }
.import-job-body a { overflow: hidden; color: var(--ink); font-size: 0.86rem; text-overflow: ellipsis; white-space: nowrap; }
.import-job-body small { color: #9a3c2c; }
.import-job-status { width: fit-content; padding: 3px 7px; border-radius: 999px; color: var(--green); background: rgba(63, 91, 69, 0.1); font-size: 0.68rem; font-weight: 700; }
.import-job-processing .import-job-status { color: #9b4a21; background: rgba(216, 115, 55, 0.13); }
.import-job-failed .import-job-status { color: #9a3c2c; background: rgba(154, 60, 44, 0.12); }
.preview-panel { margin-top: 28px; color: var(--ink); background: var(--white); border: 1px solid var(--line); }
.section-heading, .editor-section-heading, .editor-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
@@ -282,7 +293,7 @@ img { display: block; max-width: 100%; }
@media (max-width: 860px) {
.import-panel, .preview-layout, .detail-hero, .detail-columns { grid-template-columns: 1fr; }
.status { grid-column: 1; }
.status, .import-queue { grid-column: 1; }
.recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-intro { min-height: 380px; }
}
+13 -14
View File
@@ -28,29 +28,28 @@
<h2 id="import-title">새 레시피 가져오기</h2>
</div>
<form id="import-form" class="import-form">
<label class="sr-only" for="source-url">Instagram 또는 YouTube URL</label>
<input id="source-url" name="url" type="url" inputmode="url"
placeholder="https://www.instagram.com/reel/..." required>
<button class="button button-primary" type="submit">레시피 가져오기</button>
<label class="sr-only" for="source-urls">Instagram 또는 YouTube URL 목록</label>
<textarea id="source-urls" name="urls" rows="5"
placeholder="URL을 한 줄에 하나씩 입력하세요.&#10;https://www.instagram.com/reel/...&#10;https://www.youtube.com/watch?v=..." required></textarea>
<button class="button button-primary" type="submit">대기열에 추가</button>
</form>
<div id="import-status" class="status" role="status" aria-live="polite" hidden></div>
</section>
<section id="preview-section" class="preview-panel" aria-labelledby="preview-title" hidden>
<div class="section-heading">
<div>
<p class="section-number">02</p>
<h2 id="preview-title">확인하고 다듬기</h2>
<div id="import-queue" class="import-queue" hidden>
<div class="import-queue-heading">
<div>
<h3>가져오기 대기열</h3>
<span id="import-job-count" class="count-label"></span>
</div>
<button id="process-import-jobs" class="button button-primary" type="button">처리 요청</button>
</div>
<button id="cancel-preview" class="button button-quiet" type="button">취소</button>
<div id="import-job-list" class="import-job-list" aria-live="polite"></div>
</div>
<form id="preview-form"></form>
</section>
<section class="library" aria-labelledby="library-title">
<div class="section-heading">
<div>
<p class="section-number">03</p>
<p class="section-number">02</p>
<h2 id="library-title">Recipe Library</h2>
</div>
<span id="recipe-count" class="count-label"></span>
+110 -142
View File
@@ -9,8 +9,10 @@ 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'),
importQueue: document.querySelector('#import-queue'),
importJobList: document.querySelector('#import-job-list'),
importJobCount: document.querySelector('#import-job-count'),
processImportJobs: document.querySelector('#process-import-jobs'),
recipeGrid: document.querySelector('#recipe-grid'),
emptyLibrary: document.querySelector('#empty-library'),
recipeCount: document.querySelector('#recipe-count'),
@@ -30,7 +32,8 @@ const elements = {
toast: document.querySelector('#toast'),
};
let previewData = null;
let importJobs = [];
let importJobPollTimer = null;
let savedRecipes = [];
let currentUser = null;
let searchQuery = '';
@@ -41,6 +44,7 @@ const selectedTags = new Set();
const SEARCH_DEBOUNCE_MS = 180;
const VISIBLE_TAG_LIMIT = 6;
const IMPORT_JOB_POLL_MS = 3000;
function escapeHtml(value) {
return String(value ?? '')
@@ -181,6 +185,75 @@ async function loadRecipes() {
renderRecipes();
}
function importJobLabel(status) {
if (status === 'pending') return '대기 중';
if (status === 'processing') return '처리 중';
return '처리 실패';
}
function importJobUrlLabel(value) {
try {
const url = new URL(value);
return `${url.hostname}${url.pathname}${url.search}`;
} catch {
return value;
}
}
function renderImportJobs() {
const pendingCount = importJobs.filter(({ status }) => status === 'pending').length;
const processingCount = importJobs.filter(({ status }) => status === 'processing').length;
elements.importQueue.hidden = importJobs.length === 0;
elements.importJobCount.textContent = `${importJobs.length}`;
elements.processImportJobs.disabled = pendingCount === 0 && processingCount === 0;
elements.processImportJobs.textContent = processingCount > 0
? `처리 재요청 (${processingCount})`
: `처리 요청${pendingCount ? ` (${pendingCount})` : ''}`;
elements.importJobList.innerHTML = importJobs.map((job) => `
<div class="import-job import-job-${escapeHtml(job.status)}">
<div class="import-job-body">
<span class="import-job-status">${importJobLabel(job.status)}</span>
<a href="${escapeHtml(job.url)}" target="_blank" rel="noreferrer">${escapeHtml(importJobUrlLabel(job.url))}</a>
${job.error?.message ? `<small>${escapeHtml(job.error.message)}</small>` : ''}
</div>
${job.status === 'failed'
? `<button class="text-button" type="button" data-retry-import-job="${escapeHtml(job.id)}">재시도</button>`
: ''}
</div>
`).join('');
}
function scheduleImportJobPoll() {
window.clearTimeout(importJobPollTimer);
importJobPollTimer = null;
if (!importJobs.some(({ status }) => status === 'processing')) return;
importJobPollTimer = window.setTimeout(async () => {
try {
await loadImportJobs();
} catch (error) {
showToast(error.message);
scheduleImportJobPoll();
}
}, IMPORT_JOB_POLL_MS);
}
async function loadImportJobs() {
const previousActiveIds = new Set(
importJobs
.filter(({ status }) => status === 'pending' || status === 'processing')
.map(({ id }) => id),
);
const { jobs } = await apiRequest('/api/import/jobs');
importJobs = jobs;
renderImportJobs();
const visibleIds = new Set(jobs.map(({ id }) => id));
if ([...previousActiveIds].some((id) => !visibleIds.has(id))) {
await loadRecipes();
}
scheduleImportJobPoll();
}
async function loadAllowedEmails() {
const { emails } = await apiRequest('/api/allowed-emails');
elements.allowedEmailList.innerHTML = emails.map((email) => {
@@ -216,166 +289,60 @@ async function openAccessManager() {
}
}
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('링크 확인 중...');
setStatus('URL을 대기열에 추가하는 중...');
try {
const url = new FormData(elements.importForm).get('url');
previewData = await apiRequest('/api/import/preview', {
const urls = String(new FormData(elements.importForm).get('urls') ?? '')
.split(/\s+/)
.map((url) => url.trim())
.filter(Boolean);
const result = await apiRequest('/api/import/jobs', {
method: 'POST',
body: JSON.stringify({ url }),
body: JSON.stringify({ urls }),
});
timers.forEach(window.clearTimeout);
setStatus('분석 완료', 'success');
renderPreview();
elements.importForm.reset();
const duplicateMessage = result.duplicateCount
? `, 중복 ${result.duplicateCount}개 제외`
: '';
setStatus(`${result.addedCount}개를 대기열에 추가했습니다${duplicateMessage}.`, 'success');
importJobs = result.jobs;
renderImportJobs();
scheduleImportJobPoll();
} 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;
elements.processImportJobs.addEventListener('click', async () => {
elements.processImportJobs.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();
await apiRequest('/api/import/jobs/process', { method: 'POST' });
await loadImportJobs();
} catch (error) {
showToast(error.message);
} finally {
button.disabled = false;
renderImportJobs();
}
});
document.querySelector('#cancel-preview').addEventListener('click', () => {
previewData = null;
elements.previewSection.hidden = true;
elements.importJobList.addEventListener('click', async (event) => {
const button = event.target.closest('[data-retry-import-job]');
if (!button) return;
button.disabled = true;
try {
await apiRequest(`/api/import/jobs/${encodeURIComponent(button.dataset.retryImportJob)}/retry`, {
method: 'POST',
});
await loadImportJobs();
} catch (error) {
showToast(error.message);
button.disabled = false;
}
});
elements.tagFilterOptions.addEventListener('click', (event) => {
@@ -484,6 +451,7 @@ bindScrollToTop(elements.scrollToTop);
currentUser = await loadSession();
appBar.setUser(currentUser);
await loadRecipes();
await loadImportJobs();
if (
currentUser.canManageAccess
&& new URLSearchParams(window.location.search).get('manage-access') === '1'