Add per-part colors, animation toggle, and import/export
This commit is contained in:
parent
39d9ad9748
commit
e552026efa
107
assets/app.js
107
assets/app.js
@ -20,6 +20,8 @@ const modalInput = document.getElementById('modalInput');
|
|||||||
const modalTitle = document.getElementById('modalTitle');
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
const hapticTick = () => { if ('vibrate' in navigator) navigator.vibrate(12); };
|
const hapticTick = () => { if ('vibrate' in navigator) navigator.vibrate(12); };
|
||||||
const installBtn = document.getElementById('installBtn');
|
const installBtn = document.getElementById('installBtn');
|
||||||
|
const importInput = document.getElementById('importFile');
|
||||||
|
const motionBtn = document.getElementById('motionBtn');
|
||||||
let lastCountPulse = null;
|
let lastCountPulse = null;
|
||||||
let lastFinishedId = null;
|
let lastFinishedId = null;
|
||||||
let fireflyTimer = null;
|
let fireflyTimer = null;
|
||||||
@ -138,6 +140,8 @@ if (isDarkMode === null) {
|
|||||||
isDarkMode = false;
|
isDarkMode = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let animationsEnabled = JSON.parse(localStorage.getItem('crochetAnimations'));
|
||||||
|
if (animationsEnabled === null) animationsEnabled = true;
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
@ -148,17 +152,90 @@ function applyTheme() {
|
|||||||
document.getElementById('themeBtn').innerHTML = '☀️';
|
document.getElementById('themeBtn').innerHTML = '☀️';
|
||||||
}
|
}
|
||||||
handleAmbientDrift();
|
handleAmbientDrift();
|
||||||
|
updateMotionBtn();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
isDarkMode = !isDarkMode;
|
isDarkMode = !isDarkMode;
|
||||||
localStorage.setItem('crochetDarkMode', isDarkMode);
|
localStorage.setItem('crochetDarkMode', isDarkMode);
|
||||||
applyTheme();
|
applyTheme();
|
||||||
document.body.classList.add('theme-animating');
|
if (animationsEnabled) {
|
||||||
setTimeout(() => document.body.classList.remove('theme-animating'), 750);
|
document.body.classList.add('theme-animating');
|
||||||
|
setTimeout(() => document.body.classList.remove('theme-animating'), 750);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
applyTheme();
|
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 ---
|
// --- Firefly Animation ---
|
||||||
function spawnFirefly({ markActive = false, source = 'ambient' } = {}) {
|
function spawnFirefly({ markActive = false, source = 'ambient' } = {}) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@ -226,6 +303,7 @@ function stopAmbientDrift() {
|
|||||||
function scheduleAmbientDrift() {
|
function scheduleAmbientDrift() {
|
||||||
const delay = 26000 + Math.random() * 18000; // 26–44s
|
const delay = 26000 + Math.random() * 18000; // 26–44s
|
||||||
fireflyTimer = setTimeout(() => {
|
fireflyTimer = setTimeout(() => {
|
||||||
|
if (!animationsEnabled) { stopAmbientDrift(); return; }
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
spawnFirefly();
|
spawnFirefly();
|
||||||
} else {
|
} else {
|
||||||
@ -237,6 +315,7 @@ function scheduleAmbientDrift() {
|
|||||||
|
|
||||||
function handleAmbientDrift() {
|
function handleAmbientDrift() {
|
||||||
stopAmbientDrift();
|
stopAmbientDrift();
|
||||||
|
if (!animationsEnabled) return;
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
spawnFirefly();
|
spawnFirefly();
|
||||||
} else {
|
} else {
|
||||||
@ -249,7 +328,7 @@ handleAmbientDrift();
|
|||||||
const logoIcon = document.querySelector('.brand-icon');
|
const logoIcon = document.querySelector('.brand-icon');
|
||||||
if (logoIcon) {
|
if (logoIcon) {
|
||||||
logoIcon.addEventListener('click', () => {
|
logoIcon.addEventListener('click', () => {
|
||||||
if (fireflyActive) return;
|
if (!animationsEnabled || fireflyActive) return;
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
spawnFirefly({ markActive: true, source: 'logo' });
|
spawnFirefly({ markActive: true, source: 'logo' });
|
||||||
} else {
|
} else {
|
||||||
@ -257,6 +336,10 @@ if (logoIcon) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const importBtn = document.getElementById('importBtn');
|
||||||
|
if (importBtn) {
|
||||||
|
importBtn.addEventListener('click', triggerImport);
|
||||||
|
}
|
||||||
|
|
||||||
const titleEl = document.getElementById('appTitle');
|
const titleEl = document.getElementById('appTitle');
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
@ -276,6 +359,7 @@ if (titleEl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerBurst() {
|
function triggerBurst() {
|
||||||
|
if (!animationsEnabled) return;
|
||||||
const burstCount = 8;
|
const burstCount = 8;
|
||||||
const spawner = isDarkMode ? spawnFirefly : spawnSeed;
|
const spawner = isDarkMode ? spawnFirefly : spawnSeed;
|
||||||
for (let i = 0; i < burstCount; i++) {
|
for (let i = 0; i < burstCount; i++) {
|
||||||
@ -299,6 +383,7 @@ if (projects.length > 0) {
|
|||||||
p.parts.forEach(pt => {
|
p.parts.forEach(pt => {
|
||||||
if (pt.max === undefined) { pt.max = null; changed = true; }
|
if (pt.max === undefined) { pt.max = null; changed = true; }
|
||||||
if (pt.note === undefined) { pt.note = ''; 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)); }
|
if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); }
|
||||||
@ -375,6 +460,12 @@ document.addEventListener('visibilitychange', async () => {
|
|||||||
part.locked = !part.locked;
|
part.locked = !part.locked;
|
||||||
save();
|
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) {
|
function togglePartFinish(pId, partId) {
|
||||||
const project = projects.find(p => p.id === pId);
|
const project = projects.find(p => p.id === pId);
|
||||||
const part = project.parts.find(pt => pt.id === partId);
|
const part = project.parts.find(pt => pt.id === partId);
|
||||||
@ -450,11 +541,11 @@ function closeModal() {
|
|||||||
if (modalState.type === 'addProject') {
|
if (modalState.type === 'addProject') {
|
||||||
const nextColor = colors[projects.length % colors.length];
|
const nextColor = colors[projects.length % colors.length];
|
||||||
projects.push({ id: Date.now(), name: val, color: nextColor, collapsed: false, parts: [] });
|
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') {
|
else if (modalState.type === 'addPart') {
|
||||||
const project = projects.find(p => p.id === modalState.pId);
|
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;
|
project.collapsed = false;
|
||||||
}
|
}
|
||||||
else if (modalState.type === 'renamePart') {
|
else if (modalState.type === 'renamePart') {
|
||||||
@ -526,6 +617,7 @@ function render() {
|
|||||||
|
|
||||||
let partsHtml = '';
|
let partsHtml = '';
|
||||||
sortedParts.forEach(part => {
|
sortedParts.forEach(part => {
|
||||||
|
const accent = part.color || project.color;
|
||||||
const isLocked = part.locked ? 'is-locked' : '';
|
const isLocked = part.locked ? 'is-locked' : '';
|
||||||
const isFinished = part.finished ? 'is-finished' : '';
|
const isFinished = part.finished ? 'is-finished' : '';
|
||||||
const isMinimized = part.minimized ? 'is-minimized' : '';
|
const isMinimized = part.minimized ? 'is-minimized' : '';
|
||||||
@ -536,6 +628,7 @@ function render() {
|
|||||||
const showSetMax = part.minimized ? 'hidden' : '';
|
const showSetMax = part.minimized ? 'hidden' : '';
|
||||||
const partNoteId = `part-note-${project.id}-${part.id}`;
|
const partNoteId = `part-note-${project.id}-${part.id}`;
|
||||||
const countId = `count-${part.id}`;
|
const countId = `count-${part.id}`;
|
||||||
|
const colorInputId = `color-${project.id}-${part.id}`;
|
||||||
const pulseClass = lastCountPulse && lastCountPulse.partId === part.id
|
const pulseClass = lastCountPulse && lastCountPulse.partId === part.id
|
||||||
? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down')
|
? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down')
|
||||||
: '';
|
: '';
|
||||||
@ -546,6 +639,8 @@ function render() {
|
|||||||
const actionsHtml = part.minimized
|
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="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Expand">▼</button></div>`
|
||||||
: `<div class="part-actions">
|
: `<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-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-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>
|
<button class="icon-btn btn-toggle-part" onclick="togglePartMinimize(${project.id}, ${part.id})" title="Minimize">▼</button>
|
||||||
@ -558,7 +653,7 @@ function render() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
partsHtml += `
|
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-header">
|
||||||
<div class="part-name-group">
|
<div class="part-name-group">
|
||||||
<label class="check-container">
|
<label class="check-container">
|
||||||
|
|||||||
@ -133,6 +133,7 @@ h1 {
|
|||||||
|
|
||||||
.header-btn.is-active { background: var(--header-text); color: var(--header-bg); }
|
.header-btn.is-active { background: var(--header-text); color: var(--header-bg); }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
.hidden-input { display: none; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@ -210,6 +211,22 @@ h1 {
|
|||||||
.btn-rename-project:hover { color: var(--project-color); }
|
.btn-rename-project:hover { color: var(--project-color); }
|
||||||
.icon-pencil { display: inline-block; transform: scaleX(-1); }
|
.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 --- */
|
||||||
.part-card {
|
.part-card {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@ -24,10 +24,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<button class="header-btn hidden" id="installBtn" title="Install app">⬇️</button>
|
<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="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="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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<input type="file" id="importFile" accept="application/json" class="hidden-input" />
|
||||||
|
|
||||||
<div class="container" id="app"></div>
|
<div class="container" id="app"></div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user