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 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; // 26–44s
|
||||
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">
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user