1762 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// --- Data Init & Colors ---
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || [];
if (!Array.isArray(patterns)) patterns = [];
let saveFlashTimer = null;
const crochetAbbrev = [
{ code: 'alt', desc: 'alternate' },
{ code: 'approx', desc: 'approximately' },
{ code: 'beg', desc: 'begin/beginning' },
{ code: 'bet', desc: 'between' },
{ code: 'bl/blo', desc: 'back loop/back loop only' },
{ code: 'bo', desc: 'bobble' },
{ code: 'bp', desc: 'back post' },
{ code: 'bpdc', desc: 'back post double crochet' },
{ code: 'bpdtr', desc: 'back post double treble crochet' },
{ code: 'bphdc', desc: 'back post half double crochet' },
{ code: 'bpsc', desc: 'back post single crochet' },
{ code: 'bptr', desc: 'back post treble crochet' },
{ code: 'cc', desc: 'contrasting color' },
{ code: 'ch', desc: 'chain stitch' },
{ code: 'ch-sp', desc: 'chain space' },
{ code: 'cl', desc: 'cluster' },
{ code: 'cont', desc: 'continue' },
{ code: 'dc', desc: 'double crochet' },
{ code: 'dc2tog', desc: 'double crochet 2 stitches together' },
{ code: 'dec', desc: 'decrease' },
{ code: 'dtr', desc: 'double treble crochet' },
{ code: 'edc', desc: 'extended double crochet' },
{ code: 'ehdc', desc: 'extended half double crochet' },
{ code: 'esc', desc: 'extended single crochet' },
{ code: 'etr', desc: 'extended treble crochet' },
{ code: 'fl/flo', desc: 'front loop/front loop only' },
{ code: 'foll', desc: 'following' },
{ code: 'fp', desc: 'front post' },
{ code: 'fpdc', desc: 'front post double crochet' },
{ code: 'fpdtr', desc: 'front post double treble crochet' },
{ code: 'fphdc', desc: 'front post half double crochet' },
{ code: 'fpsc', desc: 'front post single crochet' },
{ code: 'fptr', desc: 'front post treble crochet' },
{ code: 'hdc', desc: 'half double crochet' },
{ code: 'hdc2tog', desc: 'half double crochet 2 stitches together' },
{ code: 'inc', desc: 'increase' },
{ code: 'lp', desc: 'loop' },
{ code: 'm', desc: 'marker' },
{ code: 'mc', desc: 'main color' },
{ code: 'pat', desc: 'pattern' },
{ code: 'pc', desc: 'popcorn stitch' },
{ code: 'pm', desc: 'place marker' },
{ code: 'prev', desc: 'previous' },
{ code: 'ps/puff', desc: 'puff stitch' },
{ code: 'rem', desc: 'remaining' },
{ code: 'rep', desc: 'repeat' },
{ code: 'rnd', desc: 'round' },
{ code: 'rs', desc: 'right side' },
{ code: 'sc', desc: 'single crochet' },
{ code: 'sc2tog', desc: 'single crochet 2 stitches together' },
{ code: 'sh', desc: 'shell' },
{ code: 'sk', desc: 'skip' },
{ code: 'sl st', desc: 'slip stitch' },
{ code: 'sm/sl m', desc: 'slip marker' },
{ code: 'sp', desc: 'space' },
{ code: 'st', desc: 'stitch' },
{ code: 'tbl', desc: 'through back loop' },
{ code: 'tch/t-ch', desc: 'turning chain' },
{ code: 'tog', desc: 'together' },
{ code: 'tr', desc: 'treble crochet' },
{ code: 'tr2tog', desc: 'treble crochet 2 stitches together' },
{ code: 'trtr', desc: 'triple treble crochet' },
{ code: 'ws', desc: 'wrong side' },
{ code: 'yo', desc: 'yarn over' },
{ code: 'yoh', desc: 'yarn over hook' },
// Tunisian
{ code: 'etss', desc: 'extended Tunisian simple stitch' },
{ code: 'fwp', desc: 'forward pass' },
{ code: 'retp', desc: 'return pass' },
{ code: 'tdc', desc: 'Tunisian double crochet' },
{ code: 'tfs', desc: 'Tunisian full stitch' },
{ code: 'thdc', desc: 'Tunisian half double crochet' },
{ code: 'tks', desc: 'Tunisian knit stitch' },
{ code: 'tps', desc: 'Tunisian purl stitch' },
{ code: 'trs', desc: 'Tunisian reverse stitch' },
{ code: 'tsc', desc: 'Tunisian single crochet' },
{ code: 'tss', desc: 'Tunisian simple stitch' },
{ code: 'tslst', desc: 'Tunisian slip stitch' },
{ code: 'ttr', desc: 'Tunisian treble crochet' },
{ code: 'ttw', desc: 'Tunisian twisted' }
];
const knitAbbrev = [
{ code: 'alt', desc: 'alternate' },
{ code: 'approx', desc: 'approximately' },
{ code: 'beg', desc: 'beginning' },
{ code: 'bet', desc: 'between' },
{ code: 'BO', desc: 'bind off' },
{ code: 'byo', desc: 'backward yarn over' },
{ code: 'CC', desc: 'contrasting color' },
{ code: 'cn', desc: 'cable needle' },
{ code: 'CO', desc: 'cast on' },
{ code: 'cont', desc: 'continue' },
{ code: 'dec', desc: 'decrease' },
{ code: 'dpn', desc: 'double-pointed needles' },
{ code: 'foll', desc: 'follow' },
{ code: 'inc', desc: 'increase' },
{ code: 'k', desc: 'knit' },
{ code: 'k1B', desc: 'knit stitch in row below' },
{ code: 'kfb', desc: 'knit 1 front and back (inc)' },
{ code: 'ksp', desc: 'knit 1, slip back, pass over (dec)' },
{ code: 'k2tog', desc: 'knit 2 together (dec)' },
{ code: 'kwise', desc: 'knitwise' },
{ code: 'LH', desc: 'left hand' },
{ code: 'lp', desc: 'loop' },
{ code: 'm', desc: 'marker' },
{ code: 'M1', desc: 'make one knitwise (inc)' },
{ code: 'M1R', desc: 'make one right (inc)' },
{ code: 'M1L', desc: 'make one left (inc)' },
{ code: 'M1p', desc: 'make one purlwise (inc)' },
{ code: 'M1rp', desc: 'make one right purlwise (inc)' },
{ code: 'M1lp', desc: 'make one left purlwise (inc)' },
{ code: 'MC', desc: 'main color' },
{ code: 'p', desc: 'purl' },
{ code: 'pat', desc: 'pattern' },
{ code: 'pfb', desc: 'purl front and back (inc)' },
{ code: 'pm', desc: 'place marker' },
{ code: 'p2tog', desc: 'purl 2 together (dec)' },
{ code: 'prev', desc: 'previous' },
{ code: 'psso', desc: 'pass slipped stitch over' },
{ code: 'p2sso', desc: 'pass 2 slipped stitches over' },
{ code: 'pwise', desc: 'purlwise' },
{ code: 'rem', desc: 'remaining' },
{ code: 'rep', desc: 'repeat' },
{ code: 'rev St st', desc: 'reverse stockinette stitch' },
{ code: 'RH', desc: 'right hand' },
{ code: 'rnd', desc: 'round' },
{ code: 'RS', desc: 'right side' },
{ code: 'SKP', desc: 'slip 1 k-wise, k1, pass slipped over (dec)' },
{ code: 'SK2P', desc: 'slip 1 k-wise, k2tog, pass slipped over (dec)' },
{ code: 'sl', desc: 'slip' },
{ code: 'sl1k', desc: 'slip 1 knitwise' },
{ code: 'sl1p', desc: 'slip 1 purlwise' },
{ code: 'sl st', desc: 'slip stitch' },
{ code: 'sm', desc: 'slip marker' },
{ code: 'ssk', desc: 'slip 2 k-wise, k through back loops (dec)' },
{ code: 'ssp', desc: 'slip 2 k-wise, return, p through back loops (dec)' },
{ code: 'sssk', desc: 'slip 3 k-wise, k through back loops (double dec)' },
{ code: 'sssp', desc: 'slip 3 k-wise, return, p through back loops (double dec)' },
{ code: 'S2KP2', desc: 'slip 2 as if to k2tog, k1, pass 2 over (double dec)' },
{ code: 'SSPP2', desc: 'slip 2 k-wise, return, p2tog tbl, p1, pass 2 over (double dec)' },
{ code: 'st', desc: 'stitch' },
{ code: 'St st', desc: 'stockinette stitch' },
{ code: 'tbl', desc: 'through back loop' },
{ code: 'tfl', desc: 'through front loop' },
{ code: 'tog', desc: 'together' },
{ code: 'WS', desc: 'wrong side' },
{ code: 'w&t', desc: 'wrap and turn' },
{ code: 'wyib', desc: 'with yarn in back' },
{ code: 'wyif', desc: 'with yarn in front' },
{ code: 'yb', desc: 'yarn back' },
{ code: 'yfwd/yf', desc: 'yarn forward' },
{ code: 'yo', desc: 'yarn over' },
{ code: 'yon', desc: 'yarn over needle' },
{ code: 'yrn', desc: 'yarn round needle' }
];
const crochetBuckets = [
{ label: 'Core stitches', codes: ['ch', 'sc', 'hdc', 'dc', 'tr', 'sl st'] },
{ label: 'Shaping & repeats', codes: ['inc', 'dec', 'tog', 'rep', 'rem', 'rnd', 'rs', 'ws', 'tch/t-ch'] },
{ label: 'Placement & loops', codes: ['fl/flo', 'bl/blo', 'tbl', 'lp', 'sp', 'ch-sp', 'prev'] },
{ label: 'Post stitches', codes: ['fp', 'fpdc', 'fptr', 'fpsc', 'fphdc', 'fpdtr', 'bp', 'bpdc', 'bptr', 'bpsc', 'bphdc', 'bpdtr'] },
{ label: 'Extended & special', codes: ['pat', 'pc', 'ps/puff', 'cl', 'sh', 'dtr', 'edc', 'ehdc', 'esc', 'etr', 'tr2tog', 'trtr', 'yo', 'yoh'] },
{ label: 'Markers & color', codes: ['mc', 'cc', 'm', 'pm', 'sm/sl m'] },
{ label: 'Tunisian', codes: ['etss', 'fwp', 'retp', 'tdc', 'tfs', 'thdc', 'tks', 'tps', 'trs', 'tsc', 'tss', 'tslst', 'ttr', 'ttw'] },
{ label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont', 'foll', 'pat', 'prev'] }
];
const knitBuckets = [
{ label: 'Core stitches', codes: ['k', 'p', 'yo', 'sl st', 'St st', 'rev St st'] },
{ label: 'Increases', codes: ['inc', 'M1', 'M1R', 'M1L', 'M1p', 'M1rp', 'M1lp', 'kfb', 'pfb'] },
{ label: 'Decreases', codes: ['dec', 'k2tog', 'ssk', 'SKP', 'SK2P', 'ssp', 'sssk', 'sssp', 'S2KP2', 'SSPP2', 'p2tog', 'psso', 'p2sso', 'ksp', 'tog'] },
{ label: 'Slips & markers', codes: ['sl', 'sl1k', 'sl1p', 'm', 'pm', 'sm', 'cn', 'dpn'] },
{ label: 'Yarn moves', codes: ['wyib', 'wyif', 'yfwd/yf', 'yb', 'yon', 'yrn', 'w&t'] },
{ label: 'Cast on / bind off', codes: ['CO', 'BO'] },
{ label: 'Color & pattern', codes: ['MC', 'CC', 'pat'] },
{ label: 'Positioning', codes: ['k1B', 'tbl', 'tfl', 'lp', 'prev'] },
{ label: 'Rows & sides', codes: ['rep', 'rem', 'RS', 'WS', 'rnd', 'foll'] },
{ label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont'] }
];
const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end'];
const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']);
function getActiveAbbrevLibrary() {
return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
}
function getAbbrevByCode(code) {
return crochetAbbrev.find(a => a.code === code) || knitAbbrev.find(a => a.code === code);
}
function buildAbbrevGroups(library, buckets) {
const index = new Map(library.map(item => [item.code, item]));
const used = new Set();
const groups = [];
buckets.forEach(bucket => {
const items = [];
bucket.codes.forEach(code => {
const item = index.get(code);
if (item && !used.has(code)) {
items.push(item);
used.add(code);
}
});
if (items.length) groups.push({ label: bucket.label, items });
});
const leftover = library.filter(item => !used.has(item.code));
if (leftover.length) groups.push({ label: 'Other', items: leftover });
return groups;
}
function getAbbrevGroups() {
const library = getActiveAbbrevLibrary();
const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets;
return buildAbbrevGroups(library, buckets);
}
function getCoreCodes() {
const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets;
const core = buckets.find(b => b.label === 'Core stitches');
return core ? core.codes : [];
}
function getPatternButtonCodes() {
const base = (patternDraft.abbrevSelection && patternDraft.abbrevSelection.length)
? patternDraft.abbrevSelection
: (patternDraft.mode === 'knit'
? ['k', 'p', 'yo', 'k2tog', 'ssk', 'sl st']
: ['ch', 'sc', 'hdc', 'dc', 'tr', 'inc', 'dec', 'sl st', 'sk', 'rep']);
const merged = [...base, ...repeatTokens];
return Array.from(new Set(merged));
}
function normalizePatternDraft(d = {}) {
const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' };
const base = {
mode: 'crochet',
currentRow: 1,
line: '',
output: '',
meta: { title: '', designer: '' },
materials: '',
gauge: '',
gaugeSts: '',
gaugeRows: '',
gaugeHook: '',
size: '',
abbrev: '',
abbrevSelection: [],
stitches: '',
notes: '',
steps: []
};
const merged = { ...base, ...d };
merged.meta = { ...base.meta, ...(d.meta || {}) };
merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : [];
merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : [];
merged.materials = merged.materials || '';
merged.gauge = merged.gauge || '';
merged.gaugeSts = merged.gaugeSts || '';
merged.gaugeRows = merged.gaugeRows || '';
merged.gaugeHook = merged.gaugeHook || '';
merged.size = merged.size || '';
merged.abbrev = merged.abbrev || '';
merged.stitches = merged.stitches || '';
merged.notes = merged.notes || '';
if (!merged.mode) merged.mode = 'crochet';
if (!merged.currentRow) merged.currentRow = 1;
if (merged.line === undefined) merged.line = '';
if (merged.output === undefined) merged.output = '';
return merged;
}
let patternDraft = normalizePatternDraft(JSON.parse(localStorage.getItem('crochetPatternDraft')) || {});
// 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
'#6d5947', // Walnut
'#c4b08a', // Oat
'#7c7565', // Stone
'#8ca58c' // Meadow
];
const oldColors = [
'#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); };
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 customColorInput = document.getElementById('customColorInput');
const saveOverlay = document.getElementById('saveOverlay');
const saveList = document.getElementById('saveList');
const patternPicker = document.getElementById('patternPicker');
const patternSelect = document.getElementById('patternSelect');
const patternOverlay = document.getElementById('patternOverlay');
const patternRowNumber = null;
const patternLine = null;
const patternOutput = document.getElementById('patternOutput');
const patternButtonsWrap = null;
if (patternPicker && patternSelect) populatePatternSelect();
if (patternOverlay) {
patternOverlay.addEventListener('click', (e) => {
if (e.target === patternOverlay) closePatternComposer();
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && patternOverlay && patternOverlay.classList.contains('active')) {
closePatternComposer();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && patternOverlay && patternOverlay.classList.contains('active')) {
e.preventDefault();
addStep();
}
});
function persistPatternDraft() {
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
flashSave();
}
function setPatternMode(mode) {
patternDraft.mode = mode;
persistPatternDraft();
if (!document.querySelector('.pattern-overlay')) return;
document.querySelectorAll('.pattern-mode').forEach(btn => {
btn.classList.toggle('is-active', btn.dataset.mode === mode);
});
renderAbbrevChecklist();
renderSteps();
}
function loadDefaultAbbrev() {
patternDraft.abbrevSelection = getCoreCodes();
updateAbbrevFromSelection();
renderAbbrevChecklist();
}
function bindPatternInputs() {
const titleEl = document.getElementById('patternTitle');
const designerEl = document.getElementById('patternDesigner');
const materialsEl = document.getElementById('patternMaterials');
const gaugeEl = document.getElementById('patternGauge');
const gaugeStsEl = document.getElementById('patternGaugeSts');
const gaugeRowsEl = document.getElementById('patternGaugeRows');
const gaugeHookEl = document.getElementById('patternGaugeHook');
const sizeEl = document.getElementById('patternSize');
const abbrevEl = document.getElementById('patternAbbrev');
const stitchesEl = document.getElementById('patternStitches');
const notesEl = document.getElementById('patternNotes');
const outputEl = document.getElementById('patternOutput');
if (titleEl) titleEl.addEventListener('input', e => { updateMetaField('title', e.target.value); });
if (designerEl) designerEl.addEventListener('input', e => { updateMetaField('designer', e.target.value); });
if (materialsEl) materialsEl.addEventListener('input', e => { updatePatternField('materials', e.target.value); });
if (gaugeEl) gaugeEl.addEventListener('input', e => { updatePatternField('gauge', e.target.value); });
if (gaugeStsEl) gaugeStsEl.addEventListener('input', e => { updatePatternField('gaugeSts', e.target.value); });
if (gaugeRowsEl) gaugeRowsEl.addEventListener('input', e => { updatePatternField('gaugeRows', e.target.value); });
if (gaugeHookEl) gaugeHookEl.addEventListener('input', e => { updatePatternField('gaugeHook', e.target.value); });
if (sizeEl) sizeEl.addEventListener('input', e => { updatePatternField('size', e.target.value); });
if (abbrevEl) abbrevEl.addEventListener('input', e => { updatePatternField('abbrev', e.target.value); });
if (stitchesEl) stitchesEl.addEventListener('input', e => { updatePatternField('stitches', e.target.value); });
if (notesEl) notesEl.addEventListener('input', e => { updatePatternField('notes', e.target.value); });
if (outputEl) outputEl.addEventListener('input', e => {
patternDraft.output = e.target.value;
const lines = patternDraft.output.split('\n').filter(l => l.trim() !== '');
patternDraft.currentRow = Math.max(1, lines.length + 1);
persistPatternDraft();
});
}
function syncPatternUI() {
setPatternMode(patternDraft.mode);
const titleEl = document.getElementById('patternTitle');
const designerEl = document.getElementById('patternDesigner');
const materialsEl = document.getElementById('patternMaterials');
const gaugeEl = document.getElementById('patternGauge');
const gaugeStsEl = document.getElementById('patternGaugeSts');
const gaugeRowsEl = document.getElementById('patternGaugeRows');
const gaugeHookEl = document.getElementById('patternGaugeHook');
const sizeEl = document.getElementById('patternSize');
const abbrevEl = document.getElementById('patternAbbrev');
const stitchesEl = document.getElementById('patternStitches');
const notesEl = document.getElementById('patternNotes');
if (titleEl) titleEl.value = patternDraft.meta.title;
if (designerEl) designerEl.value = patternDraft.meta.designer;
if (materialsEl) materialsEl.value = patternDraft.materials;
if (gaugeEl) gaugeEl.value = patternDraft.gauge;
if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts;
if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows;
if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook;
if (sizeEl) sizeEl.value = patternDraft.size;
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
if (notesEl) notesEl.value = patternDraft.notes;
renderSteps();
renderAbbrevChecklist();
}
function addPatternToken(tok) {
if (!patternLine) return;
patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok;
patternLine.value = patternDraft.line;
persistPatternDraft();
}
function clearPatternLine() {
if (!patternLine) return;
patternDraft.line = '';
patternLine.value = '';
persistPatternDraft();
}
function addPatternRow() {
if (!patternLine || !patternOutput) return;
const line = patternDraft.line.trim();
if (!line) return;
const rowLabel = patternDraft.mode === 'knit' ? 'Row' : 'Rnd';
const rowText = `${rowLabel} ${patternDraft.currentRow}: ${line}`;
patternDraft.output = patternDraft.output ? `${patternDraft.output}\n${rowText}` : rowText;
patternDraft.currentRow += 1;
patternDraft.line = '';
persistPatternDraft();
syncPatternUI();
}
function removePatternRow() {
if (!patternOutput) return;
const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== '');
if (lines.length === 0) return;
lines.pop();
patternDraft.output = lines.join('\n');
patternDraft.currentRow = Math.max(1, lines.length + 1);
persistPatternDraft();
syncPatternUI();
}
function clearPatternOutput() {
if (!patternOutput) return;
patternDraft.output = '';
patternDraft.currentRow = 1;
patternDraft.line = '';
patternDraft.meta = { title: '', designer: '' };
patternDraft.materials = '';
patternDraft.gauge = '';
patternDraft.gaugeSts = '';
patternDraft.gaugeRows = '';
patternDraft.gaugeHook = '';
patternDraft.size = '';
patternDraft.abbrev = '';
patternDraft.stitches = '';
patternDraft.notes = '';
patternDraft.steps = [];
if (patternOutput) patternOutput.value = '';
persistPatternDraft();
syncPatternUI();
}
function exportPatternJSON() {
const payload = { patternDraft };
const name = patternDraft.meta.title || 'pattern';
const filename = `${name.replace(/\s+/g,'_').toLowerCase()}_draft.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 importPatternJSON() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.patternDraft) throw new Error('Invalid file');
patternDraft = normalizePatternDraft(data.patternDraft);
persistPatternDraft();
syncPatternUI();
} catch (err) {
showAlert({ title: 'Import failed', text: err.message });
}
};
input.click();
}
function exportPatternPDF() {
const w = window.open('', '_blank');
if (!w) return;
const styles = getComputedStyle(document.body);
const themeBg = styles.getPropertyValue('--bg') || '#f4f0e8';
const themeText = styles.getPropertyValue('--text') || '#2f2b28';
const accent = styles.getPropertyValue('--project-color') || '#7a8c6a';
const gaugeBlock = `${patternDraft.gaugeSts || ''}${patternDraft.gaugeRows ? ' • ' + patternDraft.gaugeRows : ''}${patternDraft.gaugeHook ? ' • ' + patternDraft.gaugeHook : ''}`;
const fontLink = '<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">';
const html = `
<html>
<head>
<title>${patternDraft.meta.title || 'Pattern'} - PDF</title>
${fontLink}
<style>
@media print {
body { -webkit-print-color-adjust: exact; }
}
body { font-family: 'Nunito', 'Segoe UI', sans-serif; background: ${themeBg}; color: ${themeText}; padding: 20px; }
h1, h2, h3 { font-family: 'Playfair Display', Georgia, serif; margin-bottom: 6px; }
.section { margin-bottom: 16px; padding: 10px; border: 1px solid ${accent}30; border-radius: 10px; }
.section-title { font-size: 1.1rem; margin-bottom: 6px; border-bottom: 1px solid ${accent}50; padding-bottom: 4px; color: ${accent}; }
pre { white-space: pre-wrap; background: ${themeBg}; border: 1px solid ${themeText}20; padding: 10px; border-radius: 8px; }
hr { border: none; border-top: 1px solid ${themeText}20; margin: 10px 0; }
</style>
</head>
<body>
<h1>${patternDraft.meta.title || 'Pattern'}</h1>
<h3>${patternDraft.meta.designer || ''}</h3>
<div class="section">
<div class="section-title">Materials</div>
<pre>${patternDraft.materials || ''}</pre>
</div>
<div class="section">
<div class="section-title">Gauge / Size</div>
<pre>${gaugeBlock}\n${patternDraft.gauge || ''}\n${patternDraft.size || ''}</pre>
</div>
<div class="section">
<div class="section-title">Abbreviations</div>
<pre>${patternDraft.abbrev || ''}</pre>
</div>
<div class="section">
<div class="section-title">Stitch Guide</div>
<pre>${patternDraft.stitches || ''}</pre>
</div>
<div class="section">
<div class="section-title">Steps</div>
${patternDraft.steps.map((s, i) => {
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('<br>');
const note = s.note ? `<br>${s.note}` : '';
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows}${note}</div>`;
}).join('<hr>')}
</div>
<div class="section">
<div class="section-title">Rows</div>
<pre>${patternDraft.output || ''}</pre>
</div>
<div class="section">
<div class="section-title">Notes</div>
<pre>${patternDraft.notes || ''}</pre>
</div>
</body>
</html>
`;
w.document.write(html);
w.document.close();
w.focus();
w.print();
}
function showPatternTab(tab) {
document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab));
document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab));
}
function updateMetaField(field, value) {
patternDraft.meta[field] = value;
persistPatternDraft();
}
function updatePatternField(field, value) {
patternDraft[field] = value;
persistPatternDraft();
}
function renderSteps() {
const container = document.getElementById('patternSteps');
if (!container) return;
container.innerHTML = '';
patternDraft.steps.forEach((step, idx) => {
const card = document.createElement('div');
card.className = 'pattern-step-card card-pop';
const rows = step.rows || [];
const rowList = rows.map((r, i) => `
<div class="row-item">
<label>Row ${i + 1}</label>
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}">
<button type="button" onclick="removeStepRow(${idx}, ${i})">✕</button>
</div>
`).join('');
card.innerHTML = `
<div class="step-number">Step ${idx + 1}</div>
<label class="field-label">Title (optional)</label>
<input type="text" value="${step.title || ''}" data-idx="${idx}" data-field="title">
<div class="pattern-buttons inline-buttons">
${getPatternButtonCodes().map(tok => `<button type="button" data-tok="${tok}" data-idx="${idx}">${tok}</button>`).join('')}
</div>
<div class="pattern-row-editor">
<textarea data-idx="${idx}" data-field="rowDraft" placeholder="Build a row with the buttons above or type manually...">${step.rowDraft || ''}</textarea>
<div class="pattern-row-actions">
<button class="secondary" onclick="clearStepRow(${idx})">Clear line</button>
<button class="primary" onclick="addStepRow(${idx})">Add row</button>
</div>
</div>
<div class="pattern-row-list">${rowList}</div>
<label class="field-label">Note</label>
<textarea data-idx="${idx}" data-field="note" placeholder="Notes or assembly tips">${step.note || ''}</textarea>
<label class="field-label">Image URL (optional)</label>
<input type="text" value="${step.image || ''}" data-idx="${idx}" data-field="image" placeholder="https://...">
<div class="pattern-step-actions">
<button onclick="moveStep(${idx}, -1)">↑</button>
<button onclick="moveStep(${idx}, 1)">↓</button>
<button onclick="deleteStep(${idx})">Delete</button>
</div>
`;
card.querySelectorAll('input, textarea').forEach(el => {
el.addEventListener('input', (e) => {
const i = Number(e.target.dataset.idx);
const field = e.target.dataset.field;
if (!patternDraft.steps[i]) return;
patternDraft.steps[i][field] = e.target.value;
persistPatternDraft();
});
});
card.querySelectorAll('.pattern-buttons button').forEach(btn => {
btn.addEventListener('click', () => {
const tok = btn.dataset.tok;
addPatternTokenToStep(idx, tok);
});
});
card.querySelectorAll('input[data-row]').forEach(inp => {
inp.addEventListener('input', (e) => {
const rowIdx = Number(e.target.dataset.row);
updateStepRow(idx, rowIdx, e.target.value);
});
});
const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]');
if (rowTextarea) {
rowTextarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
addStepRow(idx);
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
addStep();
}
});
}
requestAnimationFrame(() => {
card.classList.remove('card-pop');
});
container.appendChild(card);
});
const addRow = document.createElement('div');
addRow.className = 'pattern-step-card add-step-row';
addRow.innerHTML = `
<button class="primary add-step-btn" onclick="addStep()">+ Add Step</button>
`;
container.appendChild(addRow);
}
function addStep() {
patternDraft.steps.push({ title: '', rows: [], rowDraft: '', note: '', image: '' });
persistPatternDraft();
renderSteps();
}
function addStepRow(idx) {
const step = patternDraft.steps[idx];
if (!step) return;
const line = (step.rowDraft || '').trim();
if (!line) return;
step.rows.push(line);
step.rowDraft = '';
persistPatternDraft();
renderSteps();
}
function removeStepLastRow(idx) {
// no-op: last-row removal handled per-row delete
}
function removeStepRow(idx, rowIdx) {
const step = patternDraft.steps[idx];
if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return;
step.rows.splice(rowIdx, 1);
persistPatternDraft();
renderSteps();
}
function clearStepRow(idx) {
const step = patternDraft.steps[idx];
if (!step) return;
step.rowDraft = '';
persistPatternDraft();
renderSteps();
}
function updateStepRow(idx, rowIdx, value) {
const step = patternDraft.steps[idx];
if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return;
step.rows[rowIdx] = value;
persistPatternDraft();
}
function renderAbbrevChecklist() {
const listEl = document.getElementById('patternAbbrevList');
if (!listEl) return;
listEl.innerHTML = '';
const selected = new Set(patternDraft.abbrevSelection);
getAbbrevGroups().forEach(bucket => {
const wrap = document.createElement('details');
wrap.className = 'abbrev-group';
wrap.open = true;
const summary = document.createElement('summary');
summary.textContent = `${bucket.label} (${bucket.items.length})`;
wrap.appendChild(summary);
const grid = document.createElement('div');
grid.className = 'abbrev-grid';
bucket.items.forEach(item => {
const pill = document.createElement('div');
pill.className = 'abbrev-pill';
if (selected.has(item.code)) pill.classList.add('is-selected');
pill.dataset.code = item.code;
pill.innerHTML = `
<span class="code">${item.code}</span>
<span class="desc">${item.desc}</span>
`;
pill.addEventListener('click', () => {
const code = pill.dataset.code;
if (pill.classList.contains('is-selected')) {
pill.classList.remove('is-selected');
patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code);
} else {
pill.classList.add('is-selected');
if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code);
}
updateAbbrevFromSelection();
});
grid.appendChild(pill);
});
wrap.appendChild(grid);
listEl.appendChild(wrap);
});
renderSelectedAbbrev();
}
function updateAbbrevFromSelection() {
const lines = patternDraft.abbrevSelection.map(code => {
const found = getAbbrevByCode(code);
return found ? `${found.code} ${found.desc}` : code;
});
patternDraft.abbrev = lines.join('\n');
persistPatternDraft();
const abbrevEl = document.getElementById('patternAbbrev');
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
renderSelectedAbbrev();
renderSteps();
}
function renderSelectedAbbrev() {
renderPatternButtons();
}
function renderPatternButtons() {
// Buttons are rendered per-step; no global buttons.
}
function deleteStep(idx) {
patternDraft.steps.splice(idx, 1);
persistPatternDraft();
renderSteps();
}
function moveStep(idx, dir) {
const target = idx + dir;
if (target < 0 || target >= patternDraft.steps.length) return;
const tmp = patternDraft.steps[idx];
patternDraft.steps[idx] = patternDraft.steps[target];
patternDraft.steps[target] = tmp;
persistPatternDraft();
renderSteps();
}
function addPatternTokenToStep(idx, tok) {
const step = patternDraft.steps[idx];
if (!step) return;
// Append with comma/space
if (!step.rowDraft) {
step.rowDraft = tok;
} else {
// If last token matches, increment count
const parts = step.rowDraft.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length && !nonMergeTokens.has(tok)) {
const last = parts[parts.length - 1];
const escapedTok = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = last.match(new RegExp(`^${escapedTok}(\\d+)$`, 'i'));
if (last.toLowerCase() === tok.toLowerCase()) {
parts[parts.length - 1] = `${tok}2`;
} else if (match) {
const n = parseInt(match[1], 10) + 1;
parts[parts.length - 1] = `${tok}${n}`;
} else {
parts.push(tok);
}
step.rowDraft = parts.join(', ');
} else {
parts.push(tok);
step.rowDraft = parts.join(', ');
}
}
persistPatternDraft();
renderSteps();
}
function addStepRow(idx) {
const step = patternDraft.steps[idx];
if (!step) return;
const line = (step.rowDraft || '').trim();
if (!line) return;
step.rows.push(line);
step.rowDraft = '';
persistPatternDraft();
renderSteps();
}
function removeStepRow(idx) {
const step = patternDraft.steps[idx];
if (!step || !step.rows || step.rows.length === 0) return;
step.rows.pop();
persistPatternDraft();
renderSteps();
}
function clearStepRow(idx) {
const step = patternDraft.steps[idx];
if (!step) return;
step.rowDraft = '';
persistPatternDraft();
renderSteps();
}
function openPatternComposer() {
if (!patternOverlay) return;
patternOverlay.classList.add('active');
syncPatternUI();
showPatternTab('steps');
}
function closePatternComposer() {
if (!patternOverlay) return;
patternOverlay.classList.remove('active');
}
syncPatternUI();
bindPatternInputs();
renderAbbrevChecklist();
let pendingSaveSelection = [];
let lastCountPulse = null;
let lastFinishedId = null;
let fireflyTimer = null;
let fireflyActive = false;
let titleClicks = [];
let easterEggCooling = false;
// --- Service Worker ---
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// fail silently; optional log
});
});
}
// --- Install Prompt ---
let deferredInstallPrompt = null;
const isStandalone = () =>
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
function hideInstall() {
if (installBtn) installBtn.classList.add('hidden');
}
function showInstall() {
if (installBtn) installBtn.classList.remove('hidden');
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredInstallPrompt = e;
if (!isStandalone()) showInstall();
});
window.addEventListener('appinstalled', () => {
deferredInstallPrompt = null;
hideInstall();
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
const choice = await deferredInstallPrompt.userChoice;
if (choice.outcome === 'accepted') hideInstall();
deferredInstallPrompt = null;
});
}
if (isStandalone()) hideInstall();
// --- 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;
}
}
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 = '<i class="fa-solid fa-moon"></i>';
} else {
document.body.classList.remove('dark-mode');
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('');
if (customColorInput) {
customColorInput.value = part.color || project.color || colors[0];
customColorInput.oninput = (e) => {
setPartColor(pId, partId, e.target.value);
};
}
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 savePatterns() {
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
}
function populatePatternSelect() {
if (!patternPicker || !patternSelect) return;
const hasPatterns = patterns.length > 0;
patternPicker.style.display = hasPatterns ? 'block' : 'none';
patternSelect.innerHTML = '<option value=\"\">No pattern</option>' + patterns.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
}
function exportData(selectedProjects = projects) {
const payload = {
projects: selectedProjects,
isDarkMode,
animationsEnabled,
patterns
};
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);
}
if (Array.isArray(data.patterns)) {
patterns = data.patterns;
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
}
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', side = 'any' } = {}) {
const wrap = document.createElement('div');
wrap.className = 'firefly-wrap';
const el = document.createElement('div');
el.className = 'firefly';
const top = Math.random() * 55 + 5; // 5vh60vh
const scale = 0.9 + Math.random() * 0.4;
const duration = 12 + Math.random() * 8; // 1220s
const chosenSide = side === 'any' ? ['left','right','top','bottom'][Math.floor(Math.random()*4)] : side;
let startX = '-10vw', endX = '110vw', startY = `${top}vh`, endY = `${top + (Math.random()*12 - 6)}vh`;
let midX = '25vw', midY = `${top - 6}vh`, mid2X = '65vw', mid2Y = `${top + 6}vh`;
if (chosenSide === 'right') {
startX = '110vw'; endX = '-10vw';
midX = '-25vw'; mid2X = '-65vw';
} else if (chosenSide === 'top') {
const x = Math.random()*80 + 10;
startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`;
startY = '-12vh'; endY = '110vh';
midX = `${x + 8}vw`; mid2X = `${x - 8}vw`;
midY = '25vh'; mid2Y = '65vh';
} else if (chosenSide === 'bottom') {
const x = Math.random()*80 + 10;
startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`;
startY = '110vh'; endY = '-12vh';
midX = `${x - 8}vw`; mid2X = `${x + 8}vw`;
midY = '75vh'; mid2Y = '35vh';
}
wrap.style.setProperty('--fly-scale', scale);
wrap.style.setProperty('--fly-duration', `${duration}s`);
wrap.style.setProperty('--fly-start-x', startX);
wrap.style.setProperty('--fly-start-y', startY);
wrap.style.setProperty('--fly-mid-x', midX);
wrap.style.setProperty('--fly-mid-y', midY);
wrap.style.setProperty('--fly-mid2-x', mid2X);
wrap.style.setProperty('--fly-mid2-y', mid2Y);
wrap.style.setProperty('--fly-end-x', endX);
wrap.style.setProperty('--fly-end-y', endY);
if (markActive) fireflyActive = true;
wrap.addEventListener('animationend', (e) => {
if (e.animationName !== 'fireflyGlide') return;
wrap.remove();
if (markActive) fireflyActive = false;
});
wrap.appendChild(el);
document.body.appendChild(wrap);
}
function spawnSeed({ markActive = false, source = 'ambient' } = {}) {
const wrap = document.createElement('div');
wrap.className = 'seed-wrap';
const el = document.createElement('div');
el.className = 'seed';
const top = Math.random() * 55 + 5; // 5vh60vh
const scale = 0.85 + Math.random() * 0.4;
const duration = 14 + Math.random() * 8; // 1422s
const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-824deg
const sway = 4 + Math.random() * 6; // px
const flipDur = 5 + Math.random() * 4; // 59s
const dir = ['left','right','top'][Math.floor(Math.random()*3)];
const fromLeft = dir === 'left';
let start = fromLeft ? '-12vw' : '112vw';
let mid = fromLeft ? '30vw' : '-30vw';
let end = fromLeft ? '112vw' : '-12vw';
if (dir === 'top') {
const x = Math.random()*80 + 10;
start = `${x}vw`;
mid = `${x + (Math.random()*10 - 5)}vw`;
end = `${x + (Math.random()*20 - 10)}vw`;
wrap.style.top = '-12vh';
} else {
wrap.style.top = `${top}vh`;
}
wrap.style.setProperty('--seed-scale', scale);
wrap.style.setProperty('--seed-duration', `${duration}s`);
wrap.style.setProperty('--seed-tilt', `${tilt}deg`);
wrap.style.setProperty('--seed-sway', `${sway}px`);
wrap.style.setProperty('--seed-flip-duration', `${flipDur}s`);
wrap.style.setProperty('--seed-start', start);
wrap.style.setProperty('--seed-mid', mid);
wrap.style.setProperty('--seed-end', end);
if (markActive) fireflyActive = true;
wrap.addEventListener('animationend', (e) => {
if (e.animationName !== 'seedGlide') return;
wrap.remove();
if (markActive) fireflyActive = false;
});
wrap.appendChild(el);
document.body.appendChild(wrap);
}
function stopAmbientDrift() {
if (fireflyTimer) {
clearTimeout(fireflyTimer);
fireflyTimer = null;
}
document.querySelectorAll('.firefly-wrap').forEach(el => el.remove());
document.querySelectorAll('.seed-wrap').forEach(el => el.remove());
fireflyActive = false;
}
function scheduleAmbientDrift() {
const delay = 10000 + Math.random() * 10000; // 1020s
fireflyTimer = setTimeout(() => {
if (!animationsEnabled) { stopAmbientDrift(); return; }
const selector = isDarkMode ? '.firefly-wrap' : '.seed-wrap';
let existing = document.querySelectorAll(selector).length;
if (existing === 0) {
isDarkMode ? spawnFirefly() : spawnSeed();
existing++;
} else if (existing < 5) {
isDarkMode ? spawnFirefly() : spawnSeed();
}
scheduleAmbientDrift();
}, delay);
}
function handleAmbientDrift() {
stopAmbientDrift();
if (!animationsEnabled) return;
if (isDarkMode) {
spawnFirefly();
} else {
spawnSeed();
}
scheduleAmbientDrift();
}
handleAmbientDrift();
const logoIcon = document.querySelector('.brand-icon');
if (logoIcon) {
logoIcon.addEventListener('click', () => {
if (!animationsEnabled || fireflyActive) return;
if (isDarkMode) {
spawnFirefly({ markActive: true, source: 'logo', side: 'any' });
} else {
spawnSeed({ markActive: true, source: 'logo' });
}
});
}
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) {
titleEl.addEventListener('click', () => {
const now = Date.now();
titleClicks = titleClicks.filter(ts => now - ts < 7000);
titleClicks.push(now);
if (titleClicks.length >= 5 && !easterEggCooling) {
easterEggCooling = true;
triggerBurst();
setTimeout(() => {
easterEggCooling = false;
titleClicks = [];
}, 8000);
}
});
}
function triggerBurst() {
if (!animationsEnabled) return;
const burstCount = isDarkMode ? 24 : 18;
const spawner = isDarkMode ? (opts) => spawnFirefly({ ...opts, side: 'any' }) : spawnSeed;
for (let i = 0; i < burstCount; i++) {
const jitter = Math.random() * 200;
setTimeout(() => spawner({ source: 'burst' }), i * 140 + jitter);
}
}
// --- 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; }
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)); }
}
// --- 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();
}
function renameProject(pId) {
modalState = { type: 'renameProject', pId, partId: null };
const project = projects.find(p => p.id === pId);
modalTitle.innerText = "Rename Project";
modalInput.value = project.name;
modalInput.type = "text";
modalInput.placeholder = "Project name";
modal.classList.add('active');
setTimeout(() => modalInput.focus(), 100);
}
async function deletePart(pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
if (part.locked) return;
const ok = await showConfirm({ title: 'Delete part?', text: 'This part will be removed.', confirmText: 'Delete', danger: true });
if (ok) {
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 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);
part.finished = !part.finished;
if(part.finished) { part.locked = false; lastFinishedId = part.id; } else { lastFinishedId = null; }
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();
lastCountPulse = { partId, dir: change > 0 ? 'up' : 'down' };
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();
}
}
function saveProjectAsPattern(pId) {
const project = projects.find(p => p.id === pId);
if (!project) return;
openModal('savePattern', pId);
}
// --- 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";
if (patternPicker && patternSelect) {
populatePatternSelect();
patternSelect.value = '';
}
} 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 === 'savePattern') {
modalTitle.innerText = "Save as Pattern";
const project = projects.find(p => p.id === pId);
modalInput.value = project ? `${project.name} pattern` : '';
modalInput.type = "text"; modalInput.placeholder = "Pattern name";
} 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";
}
else if (type === 'setMax') {
const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId);
if (part.locked) return;
}
if (type !== 'addProject' && patternPicker) {
patternPicker.style.display = 'none';
}
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' && modalState.type !== 'setMax') return closeModal();
if (modalState.type === 'addProject') {
const nextColor = colors[projects.length % colors.length];
const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [] };
const selectedPatternId = patternSelect ? patternSelect.value : '';
const chosenPattern = selectedPatternId ? patterns.find(p => String(p.id) === selectedPatternId) : null;
if (chosenPattern && Array.isArray(chosenPattern.parts) && chosenPattern.parts.length) {
chosenPattern.parts.forEach((pt, idx) => {
newProject.parts.push({
id: Date.now() + idx + 1,
name: pt.name || `Part ${idx + 1}`,
count: 0,
locked: false,
finished: false,
minimized: false,
max: pt.max ?? null,
color: pt.color || newProject.color,
note: ''
});
});
} else {
newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '' });
}
projects.push(newProject);
}
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, color: project.color, note: '' });
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 === 'renameProject') {
const project = projects.find(p => p.id === modalState.pId);
project.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;
}
}
}
else if (modalState.type === 'savePattern') {
const project = projects.find(p => p.id === modalState.pId);
if (project) {
const template = project.parts.map(pt => ({ name: pt.name, color: pt.color, max: pt.max }));
patterns.push({ id: Date.now(), name: val || project.name, color: project.color, parts: template });
savePatterns();
populatePatternSelect();
}
}
save();
closeModal();
}
modalInput.addEventListener("keyup", (e) => { if (e.key === "Enter") saveModal(); });
function toggleNote(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('show');
}
function updateProjectNote(e, pId) {
const project = projects.find(p => p.id === pId);
project.note = e.target.value;
localStorage.setItem('crochetCounters', JSON.stringify(projects));
}
function updatePartNote(e, pId, partId) {
const project = projects.find(p => p.id === pId);
const part = project.parts.find(pt => pt.id === partId);
part.note = e.target.value;
localStorage.setItem('crochetCounters', JSON.stringify(projects));
}
// --- 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;
}
const grid = document.createElement('div');
grid.className = 'projects-grid';
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 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 ? '<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' : '';
const showSetMax = part.minimized ? 'hidden' : '';
const partNoteId = `part-note-${project.id}-${part.id}`;
const countId = `count-${part.id}`;
const pulseClass = lastCountPulse && lastCountPulse.partId === part.id
? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down')
: '';
const finishPulseClass = part.finished && lastFinishedId === part.id ? 'finish-shimmer' : '';
const partCardId = `part-${part.id}`;
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"><i class="fa-solid fa-chevron-down"></i></button></div>`
: `<div class="part-actions">
<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}><i class="fa-solid fa-gear"></i></button>
</div>
`;
partsHtml += `
<div class="part-card ${partCardFullClass}" id="${partCardId}" style="--project-color: ${accent}">
<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>
${actionsHtml}
</div>
<div class="count-display ${pulseClass}" id="${countId}" ondblclick="openModal('manualCount', ${project.id}, ${part.id})">${part.count}</div>
${countSubtext}
<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 class="note-area" id="${partNoteId}">
<textarea placeholder="Notes for this part..." oninput="updatePartNote(event, ${project.id}, ${part.id})">${part.note || ''}</textarea>
</div>
<button class="note-toggle" onclick="toggleNote('${partNoteId}')">Notes</button>
</div>`;
});
const projectContainer = document.createElement('div');
projectContainer.className = `project-container ${projectCollapsedClass}`;
projectContainer.style = `--project-color: ${project.color}`;
const projectNoteId = `project-note-${project.id}`;
projectContainer.innerHTML = `
<div class="project-header">
<div class="project-title-group">
<button class="btn-toggle-project" onclick="toggleProjectCollapse(${project.id})">▼</button>
<span class="project-title">${project.name}</span>
<button class="btn-rename-project" onclick="renameProject(${project.id})" title="Rename project"><span class="icon-pencil">✎</span></button>
</div>
<div class="project-actions">
<button class="btn-add-part" onclick="openModal('addPart', ${project.id})">+ Part</button>
<button class="btn-save-pattern" onclick="saveProjectAsPattern(${project.id})" title="Save as pattern"><i class="fa-solid fa-swatchbook"></i></button>
<button class="btn-delete-project" onclick="deleteProject(${project.id})">×</button>
</div>
</div>
<div class="note-area" id="${projectNoteId}">
<textarea placeholder="Notes for this project..." oninput="updateProjectNote(event, ${project.id})">${project.note || ''}</textarea>
</div>
<button class="note-toggle" onclick="toggleNote('${projectNoteId}')">Notes</button>
<div class="part-list">${partsHtml}</div>
`;
grid.appendChild(projectContainer);
});
lastCountPulse = null;
lastFinishedId = null;
app.appendChild(grid);
}
render();
function flashSave() {
const el = document.getElementById('patternSaveIndicator');
if (!el) return;
el.textContent = 'Saved';
el.style.opacity = '1';
if (saveFlashTimer) clearTimeout(saveFlashTimer);
saveFlashTimer = setTimeout(() => {
el.style.opacity = '0.6';
}, 1200);
}