805 lines
32 KiB
JavaScript
805 lines
32 KiB
JavaScript
// --- 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' } = {}) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'firefly-wrap';
|
||
const el = document.createElement('div');
|
||
el.className = 'firefly';
|
||
const top = Math.random() * 55 + 5; // 5vh–60vh
|
||
const scale = 0.9 + Math.random() * 0.4;
|
||
const duration = 12 + Math.random() * 8; // 12–20s
|
||
wrap.style.top = `${top}vh`;
|
||
wrap.style.setProperty('--fly-scale', scale);
|
||
wrap.style.setProperty('--fly-duration', `${duration}s`);
|
||
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; // 5vh–60vh
|
||
const scale = 0.85 + Math.random() * 0.4;
|
||
const duration = 14 + Math.random() * 8; // 14–22s
|
||
const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-8–24deg
|
||
const sway = 4 + Math.random() * 6; // px
|
||
const fromLeft = Math.random() < 0.5;
|
||
const start = fromLeft ? '-12vw' : '112vw';
|
||
const mid = fromLeft ? '30vw' : '-30vw';
|
||
const end = fromLeft ? '112vw' : '-12vw';
|
||
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-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 = 26000 + Math.random() * 18000; // 26–44s
|
||
fireflyTimer = setTimeout(() => {
|
||
if (!animationsEnabled) { stopAmbientDrift(); return; }
|
||
if (isDarkMode) {
|
||
spawnFirefly();
|
||
} else {
|
||
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' });
|
||
} 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 = 8;
|
||
const spawner = isDarkMode ? spawnFirefly : 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();
|