Add per-part colors, animation toggle, and import/export

This commit is contained in:
chris 2025-12-10 12:04:07 -05:00
parent 39d9ad9748
commit e552026efa
3 changed files with 122 additions and 6 deletions

View File

@ -20,6 +20,8 @@ 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');
let lastCountPulse = null;
let lastFinishedId = null;
let fireflyTimer = null;
@ -138,6 +140,8 @@ if (isDarkMode === null) {
isDarkMode = false;
}
}
let animationsEnabled = JSON.parse(localStorage.getItem('crochetAnimations'));
if (animationsEnabled === null) animationsEnabled = true;
function applyTheme() {
if (isDarkMode) {
@ -148,17 +152,90 @@ function applyTheme() {
document.getElementById('themeBtn').innerHTML = '☀️';
}
handleAmbientDrift();
updateMotionBtn();
}
function toggleTheme() {
isDarkMode = !isDarkMode;
localStorage.setItem('crochetDarkMode', isDarkMode);
applyTheme();
document.body.classList.add('theme-animating');
setTimeout(() => document.body.classList.remove('theme-animating'), 750);
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 ? '✨' : '🚫';
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 exportData() {
const payload = {
projects,
isDarkMode,
animationsEnabled
};
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 = 'toadstool-counter-backup.json';
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);
}
// --- Firefly Animation ---
function spawnFirefly({ markActive = false, source = 'ambient' } = {}) {
const wrap = document.createElement('div');
@ -226,6 +303,7 @@ function stopAmbientDrift() {
function scheduleAmbientDrift() {
const delay = 26000 + Math.random() * 18000; // 2644s
fireflyTimer = setTimeout(() => {
if (!animationsEnabled) { stopAmbientDrift(); return; }
if (isDarkMode) {
spawnFirefly();
} else {
@ -237,6 +315,7 @@ function scheduleAmbientDrift() {
function handleAmbientDrift() {
stopAmbientDrift();
if (!animationsEnabled) return;
if (isDarkMode) {
spawnFirefly();
} else {
@ -249,7 +328,7 @@ handleAmbientDrift();
const logoIcon = document.querySelector('.brand-icon');
if (logoIcon) {
logoIcon.addEventListener('click', () => {
if (fireflyActive) return;
if (!animationsEnabled || fireflyActive) return;
if (isDarkMode) {
spawnFirefly({ markActive: true, source: 'logo' });
} else {
@ -257,6 +336,10 @@ if (logoIcon) {
}
});
}
const importBtn = document.getElementById('importBtn');
if (importBtn) {
importBtn.addEventListener('click', triggerImport);
}
const titleEl = document.getElementById('appTitle');
if (titleEl) {
@ -276,6 +359,7 @@ if (titleEl) {
}
function triggerBurst() {
if (!animationsEnabled) return;
const burstCount = 8;
const spawner = isDarkMode ? spawnFirefly : spawnSeed;
for (let i = 0; i < burstCount; i++) {
@ -299,6 +383,7 @@ if (projects.length > 0) {
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; }
});
});
if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); }
@ -375,6 +460,12 @@ document.addEventListener('visibilitychange', async () => {
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);
@ -450,11 +541,11 @@ function 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 });
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 });
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') {
@ -526,6 +617,7 @@ function render() {
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' : '';
@ -536,6 +628,7 @@ function render() {
const showSetMax = part.minimized ? 'hidden' : '';
const partNoteId = `part-note-${project.id}-${part.id}`;
const countId = `count-${part.id}`;
const colorInputId = `color-${project.id}-${part.id}`;
const pulseClass = lastCountPulse && lastCountPulse.partId === part.id
? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down')
: '';
@ -546,6 +639,8 @@ function render() {
const actionsHtml = part.minimized
? `<div class="part-actions"><button class="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Expand">▼</button></div>`
: `<div class="part-actions">
<button class="btn-color" style="--project-color: ${accent}" onclick="document.getElementById('${colorInputId}').click()" title="Set color"></button>
<input type="color" id="${colorInputId}" class="color-input" value="${accent}" onchange="setPartColor(${project.id}, ${part.id}, this.value)">
<button class="icon-btn btn-reset-part" onclick="resetCount(${project.id}, ${part.id})" ${isFinished || part.locked ? 'disabled' : ''}></button>
<button class="icon-btn btn-delete-part" onclick="deletePart(${project.id}, ${part.id})" ${lockDisabled}>🗑</button>
<button class="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Minimize"></button>
@ -558,7 +653,7 @@ function render() {
`;
partsHtml += `
<div class="part-card ${partCardFullClass}" id="${partCardId}">
<div class="part-card ${partCardFullClass}" id="${partCardId}" style="--project-color: ${accent}">
<div class="part-header">
<div class="part-name-group">
<label class="check-container">

View File

@ -133,6 +133,7 @@ h1 {
.header-btn.is-active { background: var(--header-text); color: var(--header-bg); }
.hidden { display: none !important; }
.hidden-input { display: none; }
.container {
max-width: 1200px;
@ -210,6 +211,22 @@ h1 {
.btn-rename-project:hover { color: var(--project-color); }
.icon-pencil { display: inline-block; transform: scaleX(-1); }
.btn-color {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--project-color);
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 2px var(--card-bg);
}
.btn-color:hover { box-shadow: inset 0 0 0 2px var(--project-color); }
.color-input { display: none; }
/* --- PART CARD --- */
.part-card {
background: var(--card-bg);

View File

@ -24,10 +24,14 @@
</div>
<div class="header-controls">
<button class="header-btn hidden" id="installBtn" title="Install app">⬇️</button>
<button class="header-btn" id="motionBtn" onclick="toggleAnimations()" title="Toggle Animations"></button>
<button class="header-btn" id="themeBtn" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
<button class="header-btn" id="focusBtn" onclick="toggleFocusMode()" title="Focus Mode (Keeps Screen On)">👁️</button>
<button class="header-btn" id="exportBtn" onclick="exportData()" title="Export data">⬆️</button>
<button class="header-btn" id="importBtn" title="Import data">⬇️</button>
</div>
</header>
<input type="file" id="importFile" accept="application/json" class="hidden-input" />
<div class="container" id="app"></div>