recipe-manager/public/share-list.js

316 lines
10 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.

let sharedListData = null;
let sharedShareToken = null;
let consolidateItems = true;
let groupByMode = 'aisle';
const AISLE_ORDER = [
'Produce',
'Meat & Seafood',
'Dairy & Eggs',
'Canned & Jarred',
'Bakery & Grains',
'Pantry & Spices',
'Frozen',
'Other'
];
const AISLE_ICONS = {
'Produce': 'apple',
'Meat & Seafood': 'beef',
'Dairy & Eggs': 'milk',
'Canned & Jarred': 'archive',
'Bakery & Grains': 'wheat',
'Pantry & Spices': 'flame',
'Frozen': 'snowflake',
'Other': 'shopping-bag'
};
window.addEventListener('DOMContentLoaded', () => {
initTheme();
lucide.createIcons();
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('shared-toggle-consolidate').addEventListener('change', (e) => {
consolidateItems = e.target.checked;
renderSharedList();
});
document.getElementById('shared-group-by').addEventListener('change', (e) => {
groupByMode = e.target.value;
renderSharedList();
});
document.getElementById('btn-shared-copy-text').addEventListener('click', handleCopySharedText);
const params = new URLSearchParams(window.location.search);
sharedShareToken = params.get('token');
if (!sharedShareToken) {
showNotFound();
return;
}
loadSharedShoppingList();
});
function initTheme() {
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
}
function toggleTheme() {
document.body.classList.toggle('dark-theme');
const isDark = document.body.classList.contains('dark-theme');
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
}
function showNotFound() {
document.getElementById('share-list-block').style.display = 'none';
document.getElementById('list-not-found-block').style.display = 'flex';
lucide.createIcons();
}
async function loadSharedShoppingList() {
try {
const res = await fetch(`/api/share/shopping-lists/${encodeURIComponent(sharedShareToken)}`);
if (!res.ok) {
showNotFound();
return;
}
sharedListData = await res.json();
document.title = `${sharedListData.name} – Shared Shopping List | ForkFolio`;
document.getElementById('shared-list-title').textContent = sharedListData.name;
document.getElementById('shared-list-creator').textContent = `@${sharedListData.createdBy || 'chef'}`;
document.getElementById('share-list-block').style.display = 'block';
renderSharedList();
} catch (err) {
console.error('Failed to load shared shopping list:', err);
showNotFound();
}
}
function formatScaledNumber(num) {
if (num % 1 === 0) return num.toString();
const rounded = Math.round(num * 100) / 100;
const decimal = rounded % 1;
const integer = Math.floor(rounded);
if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
return rounded.toString();
}
function buildDisplayEntries(items, shouldConsolidate) {
if (!shouldConsolidate) {
return items.map(item => ({
itemIds: [item.id],
displayText: item.rawText,
category: item.category || 'Other',
sources: item.recipeTitle ? [item.recipeTitle] : [],
checked: !!item.checked
}));
}
const groups = new Map();
for (const item of items) {
const normName = (item.itemName || item.rawText || '').toLowerCase().trim();
const normUnit = (item.unit || '').toLowerCase().trim();
const key = `${item.checked ? '1' : '0'}::${normName}::${normUnit}`;
if (!groups.has(key)) {
groups.set(key, {
itemIds: [item.id],
rawTexts: [item.rawText],
itemName: normName,
unit: normUnit,
totalQty: item.quantity !== null && item.quantity !== undefined ? Number(item.quantity) : null,
hasNullQty: item.quantity === null || item.quantity === undefined,
category: item.category || 'Other',
sources: item.recipeTitle ? [item.recipeTitle] : [],
checked: !!item.checked
});
} else {
const g = groups.get(key);
g.itemIds.push(item.id);
g.rawTexts.push(item.rawText);
if (item.quantity !== null && item.quantity !== undefined && !g.hasNullQty) {
g.totalQty = (g.totalQty || 0) + Number(item.quantity);
} else {
g.hasNullQty = true;
}
if (item.recipeTitle && !g.sources.includes(item.recipeTitle)) {
g.sources.push(item.recipeTitle);
}
}
}
const results = [];
for (const g of groups.values()) {
let displayText = g.rawTexts[0];
if (g.itemIds.length > 1) {
if (!g.hasNullQty && g.totalQty !== null && g.totalQty > 0) {
const qtyStr = formatScaledNumber(g.totalQty);
const unitStr = g.unit ? (g.totalQty > 1 && !g.unit.endsWith('s') && ['clove', 'can', 'jar', 'bunch', 'slice', 'piece', 'head', 'stalk', 'sprig', 'stick', 'cup'].includes(g.unit) ? `${g.unit}s ` : `${g.unit} `) : '';
const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
displayText = `${qtyStr} ${unitStr}${displayItem}`;
} else {
const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
displayText = `${displayItem} (×${g.itemIds.length})`;
}
}
results.push({
itemIds: g.itemIds,
displayText,
category: g.category,
sources: g.sources,
checked: g.checked
});
}
return results;
}
function groupEntries(entries, mode) {
const grouped = new Map();
if (mode === 'recipe') {
for (const entry of entries) {
const groupLabels = entry.sources.length > 0 ? [entry.sources.join(' + ')] : ['Manual / Household Staples'];
for (const label of groupLabels) {
if (!grouped.has(label)) grouped.set(label, []);
grouped.get(label).push(entry);
}
}
return grouped;
}
for (const aisle of AISLE_ORDER) {
const matching = entries.filter(e => (e.category || 'Other') === aisle);
if (matching.length > 0) {
matching.sort((a, b) => Number(a.checked) - Number(b.checked));
grouped.set(aisle, matching);
}
}
return grouped;
}
function renderSharedList() {
const contentEl = document.getElementById('shared-list-content');
const emptyEl = document.getElementById('shared-list-empty');
if (!sharedListData || !Array.isArray(sharedListData.items) || sharedListData.items.length === 0) {
contentEl.innerHTML = '';
emptyEl.style.display = 'flex';
lucide.createIcons();
return;
}
emptyEl.style.display = 'none';
contentEl.innerHTML = '';
const entries = buildDisplayEntries(sharedListData.items, consolidateItems);
const grouped = groupEntries(entries, groupByMode);
for (const [groupName, groupEntries] of grouped.entries()) {
const card = document.createElement('div');
card.className = 'shopping-category-card';
const iconName = AISLE_ICONS[groupName] || 'utensils';
const uncheckedCount = groupEntries.filter(e => !e.checked).length;
card.innerHTML = `
<div class="shopping-category-header">
<div class="shopping-category-title">
<i data-lucide="${iconName.toLowerCase()}"></i>
<span>${groupName}</span>
</div>
<span class="shopping-category-count">${uncheckedCount} / ${groupEntries.length}</span>
</div>
<ul class="shopping-items-list"></ul>
`;
const ul = card.querySelector('.shopping-items-list');
groupEntries.forEach(entry => {
const li = document.createElement('li');
li.className = `shopping-item-row ${entry.checked ? 'checked' : ''}`;
const sourcesHtml = entry.sources.length > 0
? `<div class="shopping-item-sources">${entry.sources.map(s => `<span class="shopping-source-tag">${s}</span>`).join('')}</div>`
: '';
li.innerHTML = `
<div class="shopping-item-main">
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<div class="shopping-item-text-wrap">
<span class="shopping-item-text">${entry.displayText}</span>
${sourcesHtml}
</div>
</div>
`;
li.addEventListener('click', () => toggleSharedItem(entry.itemIds, !entry.checked));
ul.appendChild(li);
});
contentEl.appendChild(card);
}
lucide.createIcons();
}
async function toggleSharedItem(itemIds, checked) {
try {
const res = await fetch(`/api/share/shopping-lists/${encodeURIComponent(sharedShareToken)}/items/check`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds, checked })
});
if (res.ok) {
sharedListData = await res.json();
renderSharedList();
}
} catch (err) {
console.error('Error toggling shared shopping list item:', err);
}
}
let copyTimeout = null;
async function handleCopySharedText() {
if (!sharedListData || !Array.isArray(sharedListData.items)) return;
const entries = buildDisplayEntries(sharedListData.items, consolidateItems);
const grouped = groupEntries(entries, groupByMode);
const lines = [`🛒 ${sharedListData.name}`];
for (const [groupName, groupEntries] of grouped.entries()) {
lines.push(`\n[${groupName}]`);
groupEntries.forEach(entry => {
lines.push(`${entry.checked ? '[x]' : '[ ]'} ${entry.displayText}`);
});
}
const text = lines.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
const btn = document.getElementById('btn-shared-copy-text');
if (btn) {
if (copyTimeout) clearTimeout(copyTimeout);
btn.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
btn.style.borderColor = 'var(--accent)';
btn.style.color = 'var(--accent)';
lucide.createIcons();
copyTimeout = setTimeout(() => {
btn.innerHTML = `<i data-lucide="copy"></i> <span>Copy Text</span>`;
btn.style.borderColor = '';
btn.style.color = '';
lucide.createIcons();
}, 2000);
}
}