461 lines
15 KiB
JavaScript
461 lines
15 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'),
|
|
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'),
|
|
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 importJobs = [];
|
|
let importJobPollTimer = 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;
|
|
const IMPORT_JOB_POLL_MS = 3000;
|
|
|
|
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();
|
|
}
|
|
|
|
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) => {
|
|
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);
|
|
}
|
|
}
|
|
|
|
elements.importForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const button = elements.importForm.querySelector('button');
|
|
button.disabled = true;
|
|
setStatus('URL을 대기열에 추가하는 중...');
|
|
|
|
try {
|
|
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({ urls }),
|
|
});
|
|
elements.importForm.reset();
|
|
const duplicateMessage = result.duplicateCount
|
|
? `, 중복 ${result.duplicateCount}개 제외`
|
|
: '';
|
|
setStatus(`${result.addedCount}개를 대기열에 추가했습니다${duplicateMessage}.`, 'success');
|
|
importJobs = result.jobs;
|
|
renderImportJobs();
|
|
scheduleImportJobPoll();
|
|
} catch (error) {
|
|
setStatus(error.message, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
elements.processImportJobs.addEventListener('click', async () => {
|
|
elements.processImportJobs.disabled = true;
|
|
try {
|
|
await apiRequest('/api/import/jobs/process', { method: 'POST' });
|
|
await loadImportJobs();
|
|
} catch (error) {
|
|
showToast(error.message);
|
|
renderImportJobs();
|
|
}
|
|
});
|
|
|
|
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) => {
|
|
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();
|
|
await loadImportJobs();
|
|
if (
|
|
currentUser.canManageAccess
|
|
&& new URLSearchParams(window.location.search).get('manage-access') === '1'
|
|
) {
|
|
await openAccessManager();
|
|
}
|