372 lines
16 KiB
JavaScript
372 lines
16 KiB
JavaScript
// --- Data Init & Colors ---
|
||
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
|
||
// New Earthy/Woodland Palette extracted from image vibes
|
||
const colors = [
|
||
'#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); };
|
||
|
||
// --- Service Worker ---
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||
// fail silently; optional log
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
}
|
||
|
||
function applyTheme() {
|
||
if (isDarkMode) {
|
||
document.body.classList.add('dark-mode');
|
||
document.getElementById('themeBtn').innerHTML = '🌙';
|
||
} else {
|
||
document.body.classList.remove('dark-mode');
|
||
document.getElementById('themeBtn').innerHTML = '☀️';
|
||
}
|
||
}
|
||
|
||
function toggleTheme() {
|
||
isDarkMode = !isDarkMode;
|
||
localStorage.setItem('crochetDarkMode', isDarkMode);
|
||
applyTheme();
|
||
}
|
||
applyTheme();
|
||
|
||
// --- 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; }
|
||
p.parts.forEach(pt => {
|
||
if (pt.max === undefined) { pt.max = null; 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();
|
||
}
|
||
async function deletePart(pId, partId) {
|
||
const ok = await showConfirm({ title: 'Delete part?', text: 'This part will be removed.', confirmText: 'Delete', danger: true });
|
||
if (ok) {
|
||
const project = projects.find(p => p.id === pId);
|
||
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 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;
|
||
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();
|
||
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";
|
||
}
|
||
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') 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 });
|
||
}
|
||
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 });
|
||
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 === '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(); });
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
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 isLocked = part.locked ? 'is-locked' : '';
|
||
const isFinished = part.finished ? 'is-finished' : '';
|
||
const isMinimized = part.minimized ? 'is-minimized' : '';
|
||
const lockIcon = part.locked ? '🔒' : '🔓';
|
||
const lockBtnClass = part.locked ? 'btn-lock locked-active' : 'btn-lock';
|
||
const controlsDimmed = (part.locked || part.finished) ? 'dimmed' : '';
|
||
const hideControls = part.finished ? 'hidden-controls' : '';
|
||
|
||
partsHtml += `
|
||
<div class="part-card ${isLocked} ${isFinished} ${isMinimized}">
|
||
<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>
|
||
<div class="part-actions">
|
||
<button class="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Minimize">▼</button>
|
||
<button class="icon-btn" onclick="resetCount(${project.id}, ${part.id})" ${isFinished ? 'disabled' : ''}>↺</button>
|
||
<button class="icon-btn btn-delete-part" onclick="deletePart(${project.id}, ${part.id})">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<div class="count-display" ondblclick="openModal('manualCount', ${project.id}, ${part.id})">${part.count}</div>
|
||
<div class="count-subtext">
|
||
${part.max !== null ? `<strong>${part.count}</strong> / ${part.max}` : 'No max set'}
|
||
<button class="icon-btn" onclick="openModal('setMax', ${project.id}, ${part.id})" title="Set max">⚙️</button>
|
||
</div>
|
||
<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>`;
|
||
});
|
||
|
||
const projectContainer = document.createElement('div');
|
||
projectContainer.className = `project-container ${projectCollapsedClass}`;
|
||
projectContainer.style = `--project-color: ${project.color}`;
|
||
projectContainer.innerHTML = `
|
||
<div class="project-header">
|
||
<div class="project-title-group" onclick="toggleProjectCollapse(${project.id})">
|
||
<button class="btn-toggle-project">▼</button>
|
||
<span class="project-title">${project.name}</span>
|
||
</div>
|
||
<div style="display:flex; gap:10px; align-items:center;">
|
||
<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="part-list">${partsHtml}</div>
|
||
`;
|
||
app.appendChild(projectContainer);
|
||
});
|
||
}
|
||
|
||
render();
|