Compare commits

...

5 Commits

3 changed files with 325 additions and 33 deletions

View File

@ -2,6 +2,16 @@
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
];
const oldColors = [
'#b56b54', // Rust/Mushroom
'#7a8b4f', // Olive Green
'#cba052', // Mustard/Daisy center
@ -20,6 +30,13 @@ 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 saveOverlay = document.getElementById('saveOverlay');
const saveList = document.getElementById('saveList');
let pendingSaveSelection = [];
let lastCountPulse = null;
let lastFinishedId = null;
let fireflyTimer = null;
@ -138,27 +155,156 @@ if (isDarkMode === null) {
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 = '🌙';
document.getElementById('themeBtn').innerHTML = '<i class="fa-solid fa-moon"></i>';
} else {
document.body.classList.remove('dark-mode');
document.getElementById('themeBtn').innerHTML = '☀️';
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('');
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');
@ -226,6 +372,7 @@ function stopAmbientDrift() {
function scheduleAmbientDrift() {
const delay = 26000 + Math.random() * 18000; // 2644s
fireflyTimer = setTimeout(() => {
if (!animationsEnabled) { stopAmbientDrift(); return; }
if (isDarkMode) {
spawnFirefly();
} else {
@ -237,6 +384,7 @@ function scheduleAmbientDrift() {
function handleAmbientDrift() {
stopAmbientDrift();
if (!animationsEnabled) return;
if (isDarkMode) {
spawnFirefly();
} else {
@ -249,7 +397,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 +405,20 @@ if (logoIcon) {
}
});
}
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) {
@ -276,6 +438,7 @@ if (titleEl) {
}
function triggerBurst() {
if (!animationsEnabled) return;
const burstCount = 8;
const spawner = isDarkMode ? spawnFirefly : spawnSeed;
for (let i = 0; i < burstCount; i++) {
@ -295,10 +458,15 @@ if (projects.length > 0) {
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)); }
@ -375,6 +543,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 +624,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,10 +700,11 @@ 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' : '';
const lockIcon = part.locked ? '🔒' : '🔓';
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' : '';
@ -544,21 +719,22 @@ function render() {
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"></button></div>`
? `<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="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>
<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}></button>
<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}">
<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

@ -1,14 +1,14 @@
:root {
/* --- Woodland Theme (Light) --- */
--bg: #e4e7d4; /* Sage background from image */
--card-bg: #f8f5e6; /* Creamy yarn color */
--header-bg: #866f5a; /* Wood hook color */
--header-text: #f8f5e6; /* Cream text for header */
--text: #4a3b2a; /* Deep sepia brown outlines */
--text-muted: #7a6b5a;
--border: #d1c7b7;
--shadow: 0 4px 8px rgba(74, 59, 42, 0.1); /* Warmer shadow */
--seed-opacity: 0.09;
--bg: #e6e0d0; /* parchment */
--card-bg: #f9f4e6; /* cream */
--header-bg: #8a6b52; /* warm oak */
--header-text: #fdf8f0; /* linen */
--text: #433628; /* cocoa */
--text-muted: #7a6d5c;
--border: #d6cabc;
--shadow: 0 4px 10px rgba(67, 54, 40, 0.12);
--seed-opacity: 0.06;
--modal-bg: #f8f5e6;
@ -32,13 +32,13 @@
/* --- Woodland Theme (Dark) --- */
body.dark-mode {
--bg: #2c3327; /* Deep forest background */
--card-bg: #3d362d; /* Dark wood card */
--header-bg: #4a3b2a; /* Darker wood header */
--header-text: #e4e8d5;
--text: #e4e8d5; /* Cream text */
--text-muted: #a8a095;
--border: #594e3f;
--shadow: 0 4px 8px rgba(0,0,0,0.4);
--card-bg: #3b342c; /* Dark wood card */
--header-bg: #4b3829; /* Darker wood header */
--header-text: #e9e4d7;
--text: #e9e4d7; /* Cream text */
--text-muted: #b5aa9b;
--border: #5f5245;
--shadow: 0 4px 10px rgba(0,0,0,0.45);
--modal-bg: #3d362d;
--input-bg: #2c2720;
@ -127,12 +127,88 @@ h1 {
color: var(--header-text);
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.2s;
transform: translateY(0);
font-family: inherit;
}
.header-btn:hover { transform: translateY(-1px) scale(1.03); box-shadow: 0 6px 14px rgba(0,0,0,0.12); }
.header-btn:active { transform: translateY(0) scale(0.96); box-shadow: none; }
.header-btn.is-active { background: var(--header-text); color: var(--header-bg); }
.hidden { display: none !important; }
.hidden-input { display: none; }
.color-overlay {
position: fixed;
inset: 0;
background: rgba(44, 35, 25, 0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 210;
padding: 20px;
backdrop-filter: blur(2px);
}
.color-overlay.active { display: flex; }
.color-modal {
background: var(--card-bg);
color: var(--text);
border-radius: 16px;
padding: 18px 18px 14px;
width: min(420px, 90vw);
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
border: 1px solid var(--border);
}
.color-title { margin: 0 0 12px; font-family: 'Cormorant Garamond', Georgia, serif; }
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.color-swatch {
height: 44px;
border-radius: 12px;
border: 2px solid var(--border);
cursor: pointer;
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.55);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.color-swatch:hover { transform: translateY(-1px) scale(1.02); box-shadow: inset 0 0 0 2px rgba(255,255,255,0.8); }
.color-swatch:active { transform: scale(0.98); }
.save-overlay {
position: fixed;
inset: 0;
background: rgba(44, 35, 25, 0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 210;
padding: 20px;
backdrop-filter: blur(2px);
}
.save-overlay.active { display: flex; }
.save-modal {
background: var(--card-bg);
color: var(--text);
border-radius: 16px;
padding: 18px 18px 14px;
width: min(420px, 90vw);
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
border: 1px solid var(--border);
}
.save-subtext { margin: 0 0 8px; color: var(--text-muted); }
.save-list { display: grid; gap: 8px; max-height: 220px; overflow-y: auto; margin-bottom: 10px; }
.save-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
background: var(--input-bg);
border: 1px solid var(--border);
}
.save-item input { width: 18px; height: 18px; }
.save-actions { display: flex; justify-content: flex-end; gap: 8px; }
.icon-woodland { width: 22px; height: 22px; }
.container {
max-width: 1200px;
@ -210,6 +286,21 @@ 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); }
/* --- PART CARD --- */
.part-card {
background: var(--card-bg);

View File

@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;700&family=Quicksand:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="icon" href="assets/icons/favicon.ico">
<link rel="icon" type="image/png" sizes="96x96" href="assets/icons/favicon-96x96.png">
<link rel="icon" type="image/svg+xml" href="assets/icons/favicon.svg">
@ -23,11 +24,14 @@
<h1 id="appTitle">Toadstool Cottage Counter</h1>
</div>
<div class="header-controls">
<button class="header-btn hidden" id="installBtn" title="Install app">⬇️</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 hidden" id="installBtn" title="Install app"><i class="fa-solid fa-download"></i></button>
<button class="header-btn" id="motionBtn" onclick="toggleAnimations()" title="Toggle Animations"><i class="fa-solid fa-wand-magic-sparkles"></i></button>
<button class="header-btn" id="themeBtn" onclick="toggleTheme()" title="Toggle Dark Mode"><i class="fa-solid fa-moon"></i></button>
<button class="header-btn" id="focusBtn" onclick="toggleFocusMode()" title="Focus Mode (Keeps Screen On)"><i class="fa-solid fa-eye"></i></button>
<button class="header-btn" id="saveLoadBtn" onclick="openSaveModal()" title="Save/Load"><i class="fa-solid fa-floppy-disk"></i></button>
</div>
</header>
<input type="file" id="importFile" accept="application/json" class="hidden-input" />
<div class="container" id="app"></div>
@ -44,6 +48,27 @@
</div>
</div>
<div class="color-overlay" id="colorOverlay">
<div class="color-modal">
<h3 class="color-title">Pick a color</h3>
<div class="color-grid" id="colorGrid"></div>
<button class="modal-btn btn-cancel" onclick="closeColorPicker()">Close</button>
</div>
</div>
<div class="save-overlay" id="saveOverlay">
<div class="save-modal">
<h3 class="color-title">Save or Load</h3>
<p class="save-subtext">Choose projects to include:</p>
<div class="save-list" id="saveList"></div>
<div class="save-actions">
<button class="modal-btn btn-cancel" onclick="closeSaveModal()">Cancel</button>
<button class="modal-btn btn-save" onclick="exportSelected()">Save</button>
<button class="modal-btn btn-save" onclick="triggerImport()">Load</button>
</div>
</div>
</div>
<script src="assets/app.js"></script>
<footer class="footer-bg" aria-hidden="true"></footer>
</body>