845 lines
34 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.

// --- Data Init & Colors ---
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
// New Earthy/Woodland Palette extracted from image vibes
const colors = [
'#a17d63', // Soft oak
'#7a8c6a', // Moss sage
'#c7a272', // Warm amber
'#b88b8a', // Rose clay
'#7b9189', // Sage teal
'#aa9a7a', // Wheat linen
'#5f6d57', // Forest dusk
'#b07d6f', // Terra blush
'#6d5947', // Walnut
'#c4b08a', // Oat
'#7c7565', // Stone
'#8ca58c' // Meadow
];
const oldColors = [
'#b56b54', // Rust/Mushroom
'#7a8b4f', // Olive Green
'#cba052', // Mustard/Daisy center
'#5f8a8b', // Muted Teal
'#8c6246', // Warm Wood tone
'#a87b8c', // Dusty Rose
'#4a5d43', // Deep Forest
'#9c7e63' // Taupe
];
// --- State Variables ---
let modalState = { type: null, projectId: null, partId: null };
const app = document.getElementById('app');
const modal = document.getElementById('modalOverlay');
const modalInput = document.getElementById('modalInput');
const modalTitle = document.getElementById('modalTitle');
const hapticTick = () => { if ('vibrate' in navigator) navigator.vibrate(12); };
const installBtn = document.getElementById('installBtn');
const importInput = document.getElementById('importFile');
const motionBtn = document.getElementById('motionBtn');
const colorOverlay = document.getElementById('colorOverlay');
const colorGrid = document.getElementById('colorGrid');
const customColorInput = document.getElementById('customColorInput');
const saveOverlay = document.getElementById('saveOverlay');
const saveList = document.getElementById('saveList');
let pendingSaveSelection = [];
let lastCountPulse = null;
let lastFinishedId = null;
let fireflyTimer = null;
let fireflyActive = false;
let titleClicks = [];
let easterEggCooling = false;
// --- Service Worker ---
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// fail silently; optional log
});
});
}
// --- Install Prompt ---
let deferredInstallPrompt = null;
const isStandalone = () =>
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
function hideInstall() {
if (installBtn) installBtn.classList.add('hidden');
}
function showInstall() {
if (installBtn) installBtn.classList.remove('hidden');
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredInstallPrompt = e;
if (!isStandalone()) showInstall();
});
window.addEventListener('appinstalled', () => {
deferredInstallPrompt = null;
hideInstall();
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
const choice = await deferredInstallPrompt.userChoice;
if (choice.outcome === 'accepted') hideInstall();
deferredInstallPrompt = null;
});
}
if (isStandalone()) hideInstall();
// --- Sweet-ish Alerts ---
function removeSwal() {
const existing = document.querySelector('.swal-overlay');
if (existing) existing.remove();
}
function showConfirm({ title = 'Are you sure?', text = '', confirmText = 'Yes', cancelText = 'Cancel', danger = false } = {}) {
return new Promise(resolve => {
removeSwal();
const overlay = document.createElement('div');
overlay.className = 'swal-overlay';
overlay.innerHTML = `
<div class="swal-dialog">
<div class="swal-title">${title}</div>
<div class="swal-text">${text}</div>
<div class="swal-actions">
<button class="swal-btn swal-cancel">${cancelText}</button>
<button class="swal-btn ${danger ? 'swal-danger' : 'swal-confirm'}">${confirmText}</button>
</div>
</div>
`;
const cancelBtn = overlay.querySelector('.swal-cancel');
const confirmBtn = overlay.querySelector('.swal-confirm, .swal-danger');
cancelBtn.onclick = () => { removeSwal(); resolve(false); };
confirmBtn.onclick = () => { removeSwal(); resolve(true); };
overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(false); } });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { removeSwal(); resolve(false); document.removeEventListener('keydown', onKey); }
});
document.body.appendChild(overlay);
});
}
function showAlert({ title = 'Notice', text = '' } = {}) {
return new Promise(resolve => {
removeSwal();
const overlay = document.createElement('div');
overlay.className = 'swal-overlay';
overlay.innerHTML = `
<div class="swal-dialog">
<div class="swal-title">${title}</div>
<div class="swal-text">${text}</div>
<div class="swal-actions">
<button class="swal-btn swal-confirm">OK</button>
</div>
</div>
`;
const okBtn = overlay.querySelector('.swal-confirm');
okBtn.onclick = () => { removeSwal(); resolve(); };
overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(); } });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { removeSwal(); resolve(); document.removeEventListener('keydown', onKey); }
});
document.body.appendChild(overlay);
});
}
// --- Theme Logic ---
let isDarkMode = JSON.parse(localStorage.getItem('crochetDarkMode'));
if (isDarkMode === null) {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDarkMode = true;
} else {
isDarkMode = false;
}
}
let animationsEnabled = JSON.parse(localStorage.getItem('crochetAnimations'));
if (animationsEnabled === null) animationsEnabled = true;
function applyTheme() {
if (isDarkMode) {
document.body.classList.add('dark-mode');
document.getElementById('themeBtn').innerHTML = '<i class="fa-solid fa-moon"></i>';
} else {
document.body.classList.remove('dark-mode');
document.getElementById('themeBtn').innerHTML = '<i class="fa-solid fa-sun"></i>';
}
handleAmbientDrift();
updateMotionBtn();
}
function toggleTheme() {
isDarkMode = !isDarkMode;
localStorage.setItem('crochetDarkMode', isDarkMode);
applyTheme();
if (animationsEnabled) {
document.body.classList.add('theme-animating');
setTimeout(() => document.body.classList.remove('theme-animating'), 750);
}
}
applyTheme();
function updateMotionBtn() {
if (!motionBtn) return;
motionBtn.innerHTML = animationsEnabled ? '<i class="fa-solid fa-wand-magic-sparkles"></i>' : '<i class="fa-solid fa-ban"></i>';
motionBtn.title = animationsEnabled ? 'Toggle Animations' : 'Animations disabled';
}
function toggleAnimations() {
animationsEnabled = !animationsEnabled;
localStorage.setItem('crochetAnimations', animationsEnabled);
updateMotionBtn();
if (!animationsEnabled) {
stopAmbientDrift();
document.body.classList.remove('theme-animating');
} else {
handleAmbientDrift();
}
}
function openColorPicker(pId, partId) {
if (!colorOverlay || !colorGrid) return;
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
colorGrid.innerHTML = colors.map(c => `
<button class="color-swatch" style="background:${c}" onclick="setPartColor(${pId}, ${partId}, '${c}'); closeColorPicker();"></button>
`).join('');
if (customColorInput) {
customColorInput.value = part.color || project.color || colors[0];
customColorInput.oninput = (e) => {
setPartColor(pId, partId, e.target.value);
};
}
colorOverlay.classList.add('active');
colorOverlay.dataset.projectId = pId;
colorOverlay.dataset.partId = partId;
}
function closeColorPicker() {
if (!colorOverlay) return;
colorOverlay.classList.remove('active');
colorGrid.innerHTML = '';
colorOverlay.dataset.projectId = '';
colorOverlay.dataset.partId = '';
}
function exportData(selectedProjects = projects) {
const payload = {
projects: selectedProjects,
isDarkMode,
animationsEnabled
};
const names = selectedProjects.map(p => p.name || 'Project').join('_').replace(/\s+/g, '-').slice(0, 50) || 'projects';
const filename = `toadstool_${names}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function triggerImport() {
if (!importInput) return;
importInput.value = '';
importInput.click();
}
async function handleImport(event) {
const file = event.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.projects || !Array.isArray(data.projects)) throw new Error('Invalid file');
projects = data.projects;
if (typeof data.isDarkMode === 'boolean') {
isDarkMode = data.isDarkMode;
localStorage.setItem('crochetDarkMode', isDarkMode);
}
if (typeof data.animationsEnabled === 'boolean') {
animationsEnabled = data.animationsEnabled;
localStorage.setItem('crochetAnimations', animationsEnabled);
}
localStorage.setItem('crochetCounters', JSON.stringify(projects));
applyTheme();
render();
} catch (err) {
showAlert({ title: 'Import failed', text: err.message });
}
event.target.value = '';
}
if (importInput) {
importInput.addEventListener('change', handleImport);
}
function openSaveModal() {
if (!saveOverlay || !saveList) return;
saveList.innerHTML = '';
pendingSaveSelection = projects.map(p => p.id);
projects.forEach(p => {
const item = document.createElement('label');
item.className = 'save-item';
item.innerHTML = `
<input type="checkbox" checked data-id="${p.id}">
<span>${p.name}</span>
`;
saveList.appendChild(item);
});
saveOverlay.classList.add('active');
}
function closeSaveModal() {
if (!saveOverlay) return;
saveOverlay.classList.remove('active');
saveList.innerHTML = '';
}
function exportSelected() {
if (!saveOverlay) return;
const inputs = saveList.querySelectorAll('input[type="checkbox"]');
const selectedIds = Array.from(inputs).filter(i => i.checked).map(i => Number(i.dataset.id));
if (selectedIds.length === 0) { closeSaveModal(); return; }
const selectedProjects = projects.filter(p => selectedIds.includes(p.id));
exportData(selectedProjects);
closeSaveModal();
}
// --- Firefly Animation ---
function spawnFirefly({ markActive = false, source = 'ambient', side = 'any' } = {}) {
const wrap = document.createElement('div');
wrap.className = 'firefly-wrap';
const el = document.createElement('div');
el.className = 'firefly';
const top = Math.random() * 55 + 5; // 5vh60vh
const scale = 0.9 + Math.random() * 0.4;
const duration = 12 + Math.random() * 8; // 1220s
const chosenSide = side === 'any' ? ['left','right','top','bottom'][Math.floor(Math.random()*4)] : side;
let startX = '-10vw', endX = '110vw', startY = `${top}vh`, endY = `${top + (Math.random()*12 - 6)}vh`;
let midX = '25vw', midY = `${top - 6}vh`, mid2X = '65vw', mid2Y = `${top + 6}vh`;
if (chosenSide === 'right') {
startX = '110vw'; endX = '-10vw';
midX = '-25vw'; mid2X = '-65vw';
} else if (chosenSide === 'top') {
const x = Math.random()*80 + 10;
startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`;
startY = '-12vh'; endY = '110vh';
midX = `${x + 8}vw`; mid2X = `${x - 8}vw`;
midY = '25vh'; mid2Y = '65vh';
} else if (chosenSide === 'bottom') {
const x = Math.random()*80 + 10;
startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`;
startY = '110vh'; endY = '-12vh';
midX = `${x - 8}vw`; mid2X = `${x + 8}vw`;
midY = '75vh'; mid2Y = '35vh';
}
wrap.style.setProperty('--fly-scale', scale);
wrap.style.setProperty('--fly-duration', `${duration}s`);
wrap.style.setProperty('--fly-start-x', startX);
wrap.style.setProperty('--fly-start-y', startY);
wrap.style.setProperty('--fly-mid-x', midX);
wrap.style.setProperty('--fly-mid-y', midY);
wrap.style.setProperty('--fly-mid2-x', mid2X);
wrap.style.setProperty('--fly-mid2-y', mid2Y);
wrap.style.setProperty('--fly-end-x', endX);
wrap.style.setProperty('--fly-end-y', endY);
if (markActive) fireflyActive = true;
wrap.addEventListener('animationend', (e) => {
if (e.animationName !== 'fireflyGlide') return;
wrap.remove();
if (markActive) fireflyActive = false;
});
wrap.appendChild(el);
document.body.appendChild(wrap);
}
function spawnSeed({ markActive = false, source = 'ambient' } = {}) {
const wrap = document.createElement('div');
wrap.className = 'seed-wrap';
const el = document.createElement('div');
el.className = 'seed';
const top = Math.random() * 55 + 5; // 5vh60vh
const scale = 0.85 + Math.random() * 0.4;
const duration = 14 + Math.random() * 8; // 1422s
const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-824deg
const sway = 4 + Math.random() * 6; // px
const flipDur = 5 + Math.random() * 4; // 59s
const dir = ['left','right','top'][Math.floor(Math.random()*3)];
const fromLeft = dir === 'left';
let start = fromLeft ? '-12vw' : '112vw';
let mid = fromLeft ? '30vw' : '-30vw';
let end = fromLeft ? '112vw' : '-12vw';
if (dir === 'top') {
const x = Math.random()*80 + 10;
start = `${x}vw`;
mid = `${x + (Math.random()*10 - 5)}vw`;
end = `${x + (Math.random()*20 - 10)}vw`;
wrap.style.top = '-12vh';
} else {
wrap.style.top = `${top}vh`;
}
wrap.style.setProperty('--seed-scale', scale);
wrap.style.setProperty('--seed-duration', `${duration}s`);
wrap.style.setProperty('--seed-tilt', `${tilt}deg`);
wrap.style.setProperty('--seed-sway', `${sway}px`);
wrap.style.setProperty('--seed-flip-duration', `${flipDur}s`);
wrap.style.setProperty('--seed-start', start);
wrap.style.setProperty('--seed-mid', mid);
wrap.style.setProperty('--seed-end', end);
if (markActive) fireflyActive = true;
wrap.addEventListener('animationend', (e) => {
if (e.animationName !== 'seedGlide') return;
wrap.remove();
if (markActive) fireflyActive = false;
});
wrap.appendChild(el);
document.body.appendChild(wrap);
}
function stopAmbientDrift() {
if (fireflyTimer) {
clearTimeout(fireflyTimer);
fireflyTimer = null;
}
document.querySelectorAll('.firefly-wrap').forEach(el => el.remove());
document.querySelectorAll('.seed-wrap').forEach(el => el.remove());
fireflyActive = false;
}
function scheduleAmbientDrift() {
const delay = 10000 + Math.random() * 10000; // 1020s
fireflyTimer = setTimeout(() => {
if (!animationsEnabled) { stopAmbientDrift(); return; }
const selector = isDarkMode ? '.firefly-wrap' : '.seed-wrap';
let existing = document.querySelectorAll(selector).length;
if (existing === 0) {
isDarkMode ? spawnFirefly() : spawnSeed();
existing++;
} else if (existing < 5) {
isDarkMode ? spawnFirefly() : spawnSeed();
}
scheduleAmbientDrift();
}, delay);
}
function handleAmbientDrift() {
stopAmbientDrift();
if (!animationsEnabled) return;
if (isDarkMode) {
spawnFirefly();
} else {
spawnSeed();
}
scheduleAmbientDrift();
}
handleAmbientDrift();
const logoIcon = document.querySelector('.brand-icon');
if (logoIcon) {
logoIcon.addEventListener('click', () => {
if (!animationsEnabled || fireflyActive) return;
if (isDarkMode) {
spawnFirefly({ markActive: true, source: 'logo', side: 'any' });
} else {
spawnSeed({ markActive: true, source: 'logo' });
}
});
}
if (colorOverlay) {
colorOverlay.addEventListener('click', (e) => {
if (e.target === colorOverlay) closeColorPicker();
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && colorOverlay && colorOverlay.classList.contains('active')) {
closeColorPicker();
}
});
const importBtn = document.getElementById('importBtn');
if (importBtn) {
importBtn.addEventListener('click', triggerImport);
}
const titleEl = document.getElementById('appTitle');
if (titleEl) {
titleEl.addEventListener('click', () => {
const now = Date.now();
titleClicks = titleClicks.filter(ts => now - ts < 7000);
titleClicks.push(now);
if (titleClicks.length >= 5 && !easterEggCooling) {
easterEggCooling = true;
triggerBurst();
setTimeout(() => {
easterEggCooling = false;
titleClicks = [];
}, 8000);
}
});
}
function triggerBurst() {
if (!animationsEnabled) return;
const burstCount = isDarkMode ? 24 : 18;
const spawner = isDarkMode ? (opts) => spawnFirefly({ ...opts, side: 'any' }) : spawnSeed;
for (let i = 0; i < burstCount; i++) {
const jitter = Math.random() * 200;
setTimeout(() => spawner({ source: 'burst' }), i * 140 + jitter);
}
}
// --- Focus Mode Logic ---
let wakeLock = null;
let isFocusMode = false;
const focusBtn = document.getElementById('focusBtn');
// --- Migration Check ---
if (projects.length > 0) {
let changed = false;
projects.forEach((p, index) => {
if (!p.parts) { p.parts = []; changed = true; }
if (!p.color) { p.color = colors[index % colors.length]; changed = true; }
const oldIdx = oldColors.indexOf(p.color);
if (oldIdx !== -1) { p.color = colors[oldIdx % colors.length]; changed = true; }
if (p.note === undefined) { p.note = ''; changed = true; }
p.parts.forEach(pt => {
if (pt.max === undefined) { pt.max = null; changed = true; }
if (pt.note === undefined) { pt.note = ''; changed = true; }
if (!pt.color) { pt.color = p.color; changed = true; }
const oldPartIdx = oldColors.indexOf(pt.color);
if (oldPartIdx !== -1) { pt.color = colors[oldPartIdx % colors.length]; changed = true; }
});
});
if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); }
}
// --- Core Functions ---
function save() {
localStorage.setItem('crochetCounters', JSON.stringify(projects));
render();
}
async function toggleFocusMode() {
if (!isFocusMode) {
try {
if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
}
isFocusMode = true;
focusBtn.classList.add('is-active');
} catch (err) { showAlert({ title: 'Focus Mode failed', text: err.message }); }
} else {
if (document.fullscreenElement) document.exitFullscreen();
if (wakeLock !== null) { wakeLock.release(); wakeLock = null; }
isFocusMode = false;
focusBtn.classList.remove('is-active');
}
}
document.addEventListener('visibilitychange', async () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
wakeLock = await navigator.wakeLock.request('screen');
}
});
// --- Interaction Logic ---
async function deleteProject(pId) {
const ok = await showConfirm({ title: 'Delete project?', text: 'This will remove the entire project.', confirmText: 'Delete', danger: true });
if (ok) { projects = projects.filter(p => p.id !== pId); save(); }
}
function toggleProjectCollapse(pId) {
const project = projects.find(p => p.id === pId);
project.collapsed = !project.collapsed;
save();
}
function renameProject(pId) {
modalState = { type: 'renameProject', pId, partId: null };
const project = projects.find(p => p.id === pId);
modalTitle.innerText = "Rename Project";
modalInput.value = project.name;
modalInput.type = "text";
modalInput.placeholder = "Project name";
modal.classList.add('active');
setTimeout(() => modalInput.focus(), 100);
}
async function deletePart(pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
if (part.locked) return;
const ok = await showConfirm({ title: 'Delete part?', text: 'This part will be removed.', confirmText: 'Delete', danger: true });
if (ok) {
project.parts = project.parts.filter(pt => pt.id !== partId);
save();
}
}
function togglePartMinimize(pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
part.minimized = !part.minimized;
save();
}
function togglePartLock(pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
part.locked = !part.locked;
save();
}
function setPartColor(pId, partId, color) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
part.color = color;
save();
}
function togglePartFinish(pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
part.finished = !part.finished;
if(part.finished) { part.locked = false; lastFinishedId = part.id; } else { lastFinishedId = null; }
save();
}
function updateCount(pId, partId, change) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
if (part.locked || part.finished) return;
part.count += change;
if (part.max !== null && part.count > part.max) part.count = part.max;
if (part.count < 0) part.count = 0;
hapticTick();
lastCountPulse = { partId, dir: change > 0 ? 'up' : 'down' };
save();
}
async function resetCount(pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
if (part.locked || part.finished) return;
const ok = await showConfirm({ title: 'Reset count?', text: 'Set this count back to zero.', confirmText: 'Reset', danger: true });
if(ok) {
part.count = 0;
save();
}
}
// --- Modal Logic ---
function openModal(type, pId = null, partId = null) {
modalState = { type, pId, partId };
modalInput.value = "";
if (type === 'addProject') {
modalTitle.innerText = "New Project Name";
modalInput.type = "text"; modalInput.placeholder = "e.g., Amigurumi Bear";
} else if (type === 'addPart') {
modalTitle.innerText = "New Part Name";
modalInput.type = "text"; modalInput.placeholder = "e.g., Head";
} else if (type === 'setMax') {
const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId);
modalTitle.innerText = "Set Max Stitches";
modalInput.value = part.max ?? '';
modalInput.type = "number";
modalInput.placeholder = "Leave blank to clear";
} else if (type === 'renamePart') {
const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId);
if(part.locked || part.finished) return;
modalTitle.innerText = "Rename Part";
modalInput.value = part.name; modalInput.type = "text";
} else if (type === 'manualCount') {
const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId);
if(part.locked || part.finished) return;
modalTitle.innerText = "Set Row Count";
modalInput.value = part.count; modalInput.type = "number";
}
else if (type === 'setMax') {
const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId);
if (part.locked) return;
}
modal.classList.add('active');
setTimeout(() => modalInput.focus(), 100);
}
function closeModal() {
modal.classList.remove('active');
modalInput.blur();
}
function saveModal() {
const val = modalInput.value.trim();
if (!val && modalState.type !== 'manualCount' && modalState.type !== 'setMax') return closeModal();
if (modalState.type === 'addProject') {
const nextColor = colors[projects.length % colors.length];
projects.push({ id: Date.now(), name: val, color: nextColor, collapsed: false, parts: [] });
projects[projects.length-1].parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor });
}
else if (modalState.type === 'addPart') {
const project = projects.find(p => p.id === modalState.pId);
project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color });
project.collapsed = false;
}
else if (modalState.type === 'renamePart') {
const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId);
part.name = val;
}
else if (modalState.type === 'renameProject') {
const project = projects.find(p => p.id === modalState.pId);
project.name = val;
}
else if (modalState.type === 'manualCount') {
const num = parseInt(val);
if (!isNaN(num) && num >= 0) {
const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId);
part.count = num;
if (part.max !== null && part.count > part.max) part.count = part.max;
}
}
else if (modalState.type === 'setMax') {
const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId);
if (val === '') {
part.max = null;
} else {
const num = parseInt(val);
if (!isNaN(num) && num > 0) {
part.max = num;
if (part.count > part.max) part.count = part.max;
}
}
}
save();
closeModal();
}
modalInput.addEventListener("keyup", (e) => { if (e.key === "Enter") saveModal(); });
function toggleNote(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('show');
}
function updateProjectNote(e, pId) {
const project = projects.find(p => p.id === pId);
project.note = e.target.value;
localStorage.setItem('crochetCounters', JSON.stringify(projects));
}
function updatePartNote(e, pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
part.note = e.target.value;
localStorage.setItem('crochetCounters', JSON.stringify(projects));
}
// --- Render Logic ---
function render() {
app.innerHTML = '';
if (projects.length === 0) {
app.innerHTML = '<div class="empty-state">Toadstools & twine await...<br>Tap + to begin a new project.</div>';
return;
}
const grid = document.createElement('div');
grid.className = 'projects-grid';
projects.forEach(project => {
const sortedParts = [...project.parts].sort((a, b) => a.finished - b.finished);
const projectCollapsedClass = project.collapsed ? 'project-collapsed' : '';
let partsHtml = '';
sortedParts.forEach(part => {
const accent = part.color || project.color;
const isLocked = part.locked ? 'is-locked' : '';
const isFinished = part.finished ? 'is-finished' : '';
const isMinimized = part.minimized ? 'is-minimized' : '';
const lockIcon = part.locked ? '<i class="fa-solid fa-lock"></i>' : '<i class="fa-solid fa-lock-open"></i>';
const lockBtnClass = part.locked ? 'btn-lock locked-active' : 'btn-lock';
const controlsDimmed = (part.locked || part.finished) ? 'dimmed' : '';
const hideControls = (part.finished || part.minimized) ? 'hidden-controls' : '';
const showSetMax = part.minimized ? 'hidden' : '';
const partNoteId = `part-note-${project.id}-${part.id}`;
const countId = `count-${part.id}`;
const pulseClass = lastCountPulse && lastCountPulse.partId === part.id
? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down')
: '';
const finishPulseClass = part.finished && lastFinishedId === part.id ? 'finish-shimmer' : '';
const partCardId = `part-${part.id}`;
const partCardFullClass = `${isLocked} ${isFinished} ${isMinimized} ${finishPulseClass}`;
const lockDisabled = part.locked ? 'disabled' : '';
const actionsHtml = part.minimized
? `<div class="part-actions"><button class="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Expand"><i class="fa-solid fa-chevron-down"></i></button></div>`
: `<div class="part-actions">
<button class="btn-color" style="--project-color: ${accent}" onclick="openColorPicker(${project.id}, ${part.id})" title="Set color"></button>
<button class="icon-btn btn-reset-part" onclick="resetCount(${project.id}, ${part.id})" ${isFinished || part.locked ? 'disabled' : ''}><i class="fa-solid fa-rotate-left"></i></button>
<button class="icon-btn btn-delete-part" onclick="deletePart(${project.id}, ${part.id})" ${lockDisabled}><i class="fa-solid fa-trash"></i></button>
<button class="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Minimize"><i class="fa-solid fa-chevron-down"></i></button>
</div>`;
const countSubtext = part.minimized ? '' : `
<div class="count-subtext">
${part.max !== null ? `<strong>${part.count}</strong> / ${part.max}` : 'No max set'}
<button class="icon-btn ${showSetMax}" onclick="openModal('setMax', ${project.id}, ${part.id})" title="Set max" ${lockDisabled}><i class="fa-solid fa-gear"></i></button>
</div>
`;
partsHtml += `
<div class="part-card ${partCardFullClass}" id="${partCardId}" style="--project-color: ${accent}">
<div class="part-header">
<div class="part-name-group">
<label class="check-container">
<input type="checkbox" ${part.finished ? 'checked' : ''} onchange="togglePartFinish(${project.id}, ${part.id})">
<span class="checkmark"></span>
</label>
<span class="part-name" onclick="openModal('renamePart', ${project.id}, ${part.id})">${part.name}</span>
<span class="part-mini-count">${part.count}</span>
</div>
${actionsHtml}
</div>
<div class="count-display ${pulseClass}" id="${countId}" ondblclick="openModal('manualCount', ${project.id}, ${part.id})">${part.count}</div>
${countSubtext}
<div class="controls ${hideControls}">
<button class="action-btn btn-minus ${controlsDimmed}" onclick="updateCount(${project.id}, ${part.id}, -1)">-</button>
<button class="action-btn ${lockBtnClass}" onclick="togglePartLock(${project.id}, ${part.id})">${lockIcon}</button>
<button class="action-btn btn-plus ${controlsDimmed}" onclick="updateCount(${project.id}, ${part.id}, 1)">+</button>
</div>
<div class="note-area" id="${partNoteId}">
<textarea placeholder="Notes for this part..." oninput="updatePartNote(event, ${project.id}, ${part.id})">${part.note || ''}</textarea>
</div>
<button class="note-toggle" onclick="toggleNote('${partNoteId}')">Notes</button>
</div>`;
});
const projectContainer = document.createElement('div');
projectContainer.className = `project-container ${projectCollapsedClass}`;
projectContainer.style = `--project-color: ${project.color}`;
const projectNoteId = `project-note-${project.id}`;
projectContainer.innerHTML = `
<div class="project-header">
<div class="project-title-group">
<button class="btn-toggle-project" onclick="toggleProjectCollapse(${project.id})">▼</button>
<span class="project-title">${project.name}</span>
<button class="btn-rename-project" onclick="renameProject(${project.id})" title="Rename project"><span class="icon-pencil">✎</span></button>
</div>
<div class="project-actions">
<button class="btn-add-part" onclick="openModal('addPart', ${project.id})">+ Part</button>
<button class="btn-delete-project" onclick="deleteProject(${project.id})">×</button>
</div>
</div>
<div class="note-area" id="${projectNoteId}">
<textarea placeholder="Notes for this project..." oninput="updateProjectNote(event, ${project.id})">${project.note || ''}</textarea>
</div>
<button class="note-toggle" onclick="toggleNote('${projectNoteId}')">Notes</button>
<div class="part-list">${partsHtml}</div>
`;
grid.appendChild(projectContainer);
});
lastCountPulse = null;
lastFinishedId = null;
app.appendChild(grid);
}
render();