// --- Data Init & Colors ---
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || [];
if (!Array.isArray(patterns)) patterns = [];
let auth = {
token: localStorage.getItem('authToken') || '',
email: '',
isAdmin: false,
status: 'unknown',
mode: 'login'
};
let pendingUsers = [];
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 circle' },
{ 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() {
const base = patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
return [...base, ...(patternDraft.customAbbrev || [])];
}
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));
}
const yarnWeights = [
{ val: '0', label: 'Lace', desc: 'Fingering' },
{ val: '1', label: 'Super Fine', desc: 'Sock' },
{ val: '2', label: 'Fine', desc: 'Sport' },
{ val: '3', label: 'Light', desc: 'DK' },
{ val: '4', label: 'Medium', desc: 'Worsted' },
{ val: '5', label: 'Bulky', desc: 'Chunky' },
{ val: '6', label: 'Super Bulky', desc: 'Roving' },
{ val: '7', label: 'Jumbo', desc: 'Giant' }
];
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: '',
size: '',
abbrev: '',
abbrevSelection: [],
customAbbrev: [],
stitches: '',
notes: '',
steps: [],
palette: [],
yarns: [],
hooks: [],
previewSize: 'full'
};
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.palette = Array.isArray(merged.palette) ? merged.palette : [];
merged.yarns = Array.isArray(merged.yarns) ? merged.yarns : [];
merged.hooks = Array.isArray(merged.hooks) ? merged.hooks : [];
// Migration: Legacy yarnWeight
if (d.yarnWeight && merged.yarns.length === 0) {
merged.yarns.push({ weight: d.yarnWeight, note: 'Main' });
}
// Migration: Legacy gaugeHook
if (d.gaugeHook && merged.hooks.length === 0) {
merged.hooks.push({ size: d.gaugeHook, note: 'Main' });
}
merged.materials = merged.materials || '';
merged.gauge = merged.gauge || '';
merged.gaugeSts = merged.gaugeSts || '';
merged.gaugeRows = merged.gaugeRows || '';
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')) || {});
let currentPatternId = null;
// 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 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 (sizeEl) sizeEl.value = patternDraft.size;
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
if (notesEl) notesEl.value = patternDraft.notes;
renderYarnList();
renderHookList();
renderSteps();
renderAbbrevSummary();
}
function renderYarnList() {
const el = document.getElementById('yarnList');
if (!el) return;
if (patternDraft.yarns.length === 0) {
el.innerHTML = ``;
return;
}
el.innerHTML = patternDraft.yarns.map((y, idx) => `
`).join('') + ``;
}
function addYarn() {
patternDraft.yarns.push({ weight: '4', note: '', color: '#a17d63' });
persistPatternDraft();
renderYarnList();
}
function removeYarn(idx) {
patternDraft.yarns.splice(idx, 1);
persistPatternDraft();
renderYarnList();
}
function updateYarn(idx, field, val) {
if (patternDraft.yarns[idx]) {
patternDraft.yarns[idx][field] = val;
persistPatternDraft();
if (field === 'color') renderYarnList(); // Re-render to update border color
}
}
function renderHookList() {
const el = document.getElementById('hookList');
if (!el) return;
if (patternDraft.hooks.length === 0) {
el.innerHTML = ``;
return;
}
el.innerHTML = patternDraft.hooks.map((h, idx) => `
`).join('') + ``;
}
function addHook() {
patternDraft.hooks.push({ size: '', note: '' });
persistPatternDraft();
renderHookList();
}
function removeHook(idx) {
patternDraft.hooks.splice(idx, 1);
persistPatternDraft();
renderHookList();
}
function updateHook(idx, field, val) {
if (patternDraft.hooks[idx]) {
patternDraft.hooks[idx][field] = val;
persistPatternDraft();
}
}
function renderPatternPalette() {
const el = document.getElementById('patternPaletteList');
if (!el) return;
el.innerHTML = patternDraft.palette.map((c, i) => `
`).join('');
}
function addPatternColor() {
// Open color picker but customized for pattern
// For simplicity, we'll reuse the existing color picker but hook it differently?
// Actually, let's just make a simple prompt or use the existing overlay with a special mode.
// Or just a native input.
const input = document.createElement('input');
input.type = 'color';
input.onchange = (e) => {
patternDraft.palette.push(e.target.value);
persistPatternDraft();
renderPatternPalette();
};
input.click();
}
function removePatternColor(idx) {
patternDraft.palette.splice(idx, 1);
persistPatternDraft();
renderPatternPalette();
}
function filterAbbrev() {
const term = document.getElementById('abbrevSearch').value.toLowerCase();
const items = document.querySelectorAll('.abbrev-pill');
items.forEach(el => {
const text = el.innerText.toLowerCase();
el.style.display = text.includes(term) ? 'flex' : 'none';
});
// Also hide empty groups
document.querySelectorAll('.abbrev-group').forEach(grp => {
const visible = grp.querySelectorAll('.abbrev-pill[style="display: flex;"]').length > 0;
grp.style.display = visible ? 'block' : 'none';
});
}
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 = '';
currentPatternId = null;
patternDraft.meta = { title: '', designer: '' };
patternDraft.materials = '';
patternDraft.gauge = '';
patternDraft.gaugeSts = '';
patternDraft.gaugeRows = '';
patternDraft.gaugeHook = '';
patternDraft.size = '';
patternDraft.abbrev = '';
patternDraft.stitches = '';
patternDraft.notes = '';
patternDraft.steps = [];
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
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 = '';
const html = `
${patternDraft.meta.title || 'Pattern'} - PDF
${fontLink}
${patternDraft.meta.title || 'Pattern'}
${patternDraft.meta.designer || ''}
Materials
${patternDraft.materials || ''}
Gauge / Size
${gaugeBlock}\n${patternDraft.gauge || ''}\n${patternDraft.size || ''}
Abbreviations
${patternDraft.abbrev || ''}
Stitch Guide
${patternDraft.stitches || ''}
Steps
${patternDraft.steps.map((s, i) => {
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('
');
const note = s.note ? `
Note: ${s.note}` : '';
const img = s.image ? `

` : '';
return `
Step ${i + 1}${s.title ? ': ' + s.title : ''}
${rows}${note}${img}
`;
}).join('
')}
Rows
${patternDraft.output || ''}
Notes
${patternDraft.notes || ''}
`;
w.document.write(html);
w.document.close();
w.focus();
w.print();
}
function showPatternTab(tab) {
document.querySelectorAll('.nav-item').forEach(btn =>
btn.classList.toggle('active', btn.dataset.tab === tab)
);
// Remove split view logic for simplicity
document.querySelector('.pattern-body').classList.remove('split-view');
document.querySelectorAll('.pattern-section').forEach(sec => {
sec.classList.toggle('active', sec.dataset.section === tab);
});
try { localStorage.setItem('patternActiveTab', tab); } catch (e) {}
}
window.addEventListener('resize', () => {
const activeBtn = document.querySelector('.nav-item.active');
if (activeBtn && activeBtn.dataset.tab === 'steps') {
showPatternTab('steps');
}
});
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) => `
${i + 1}.
`).join('');
card.innerHTML = `
${rowList}
${getPatternButtonCodes().map(tok => ``).join('')}
`;
// Event Listeners
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;
// Handle row updates separately
if (e.target.hasAttribute('data-row')) {
const rIdx = Number(e.target.dataset.row);
patternDraft.steps[i].rows[rIdx] = e.target.value;
} else {
patternDraft.steps[i][field] = e.target.value;
}
persistPatternDraft();
});
});
card.querySelectorAll('.pattern-buttons button').forEach(btn => {
btn.addEventListener('click', () => {
addPatternTokenToStep(idx, btn.dataset.tok);
});
});
// Enter key handling for row adding
const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]');
if (rowTextarea) {
rowTextarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
addStepRow(idx);
}
});
}
container.appendChild(card);
});
const addRow = document.createElement('div');
addRow.className = 'add-step-row';
addRow.innerHTML = ``;
container.appendChild(addRow);
}
function removeStepImage(idx) {
if (patternDraft.steps[idx]) {
patternDraft.steps[idx].image = '';
persistPatternDraft();
renderSteps();
}
}
function uploadStepImage(idx) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${auth.token}` },
body: formData
});
const data = await resp.json();
if (resp.ok) {
patternDraft.steps[idx].image = data.url;
persistPatternDraft();
renderSteps();
showAlert({ title: 'Upload success', text: 'Image added to step.' });
} else {
showAlert({ title: 'Upload failed', text: data.error || 'Error' });
}
} catch (err) {
showAlert({ title: 'Error', text: err.message });
}
};
input.click();
}
function renderPatternView() {
const container = document.getElementById('patternView');
if (!container) return;
const steps = patternDraft.steps || [];
const meta = patternDraft.meta || {};
const mats = patternDraft.materials || '';
const gauge = patternDraft.gauge || '';
const gaugeSts = patternDraft.gaugeSts || '';
const gaugeRows = patternDraft.gaugeRows || '';
const size = patternDraft.size || '';
// Construct displayedHooks from multiple hooks if available
const displayedHooks = patternDraft.hooks.map(h => h.size).filter(Boolean).join(' / ');
container.className = `pattern-view ${patternDraft.previewSize}`;
container.innerHTML = `
${meta.title || 'Pattern'}
${meta.designer || ''}
${mats ? `
Materials
${mats}` : ''}
${(gauge || gaugeSts || gaugeRows || displayedHooks || size) ? `
Gauge / Size
${[gaugeSts, gaugeRows, displayedHooks, size, gauge].filter(Boolean).join(' • ')}` : ''}
Yarn & Tools
${patternDraft.yarns.length ? `
${patternDraft.yarns.map(y => `- ${y.note || 'Yarn'}: Weight ${y.weight} ${y.color ? `` : ''}
`).join('')}
` : ''}
${patternDraft.hooks.length ? `
Hooks: ${patternDraft.hooks.map(h => `${h.size} ${h.note ? `(${h.note})` : ''}`).join(', ')}
` : ''}
${!patternDraft.yarns.length && !patternDraft.hooks.length ? '
No yarn or tools listed.
' : ''}
Key
${patternDraft.abbrevSelection.length ? `
${patternDraft.abbrevSelection.map(c => { const i = getAbbrevByCode(c); return `
${c} – ${i ? i.desc : ''}
`; }).join('')}
` : '
No abbreviations selected.
'}
Steps
${steps.map((st, i) => `
${(st.rows || []).map((r, idx) => `- Row ${idx + 1}: ${r}
`).join('') || '- No rows yet.
'}
${st.image ? `

` : ''}
`).join('')}
`;
container.querySelectorAll('input[type="checkbox"][data-view-step]').forEach(cb => {
cb.addEventListener('change', (e) => {
const idx = Number(e.target.dataset.viewStep);
if (!patternDraft.steps[idx]) return;
patternDraft.steps[idx].finished = e.target.checked;
persistPatternDraft();
const card = e.target.closest('.view-step');
if (card) {
card.classList.toggle('is-finished', e.target.checked);
card.classList.toggle('minimized', e.target.checked);
}
});
});
container.querySelectorAll('textarea[data-view-note]').forEach(area => {
area.addEventListener('input', (e) => {
const idx = Number(e.target.dataset.viewNote);
if (!patternDraft.steps[idx]) return;
patternDraft.steps[idx].viewNote = e.target.value;
persistPatternDraft();
});
});
}
function startProjectFromDraft() {
savePatternDraft(); // Ensure pattern is saved and has ID
closePatternComposer();
// Open project modal
openModal('addProject');
// Pre-select this pattern (need a slight delay to ensure modal logic runs first if async, but it's sync)
if (currentPatternId && patternSelect) {
patternSelect.value = currentPatternId;
// Optionally set default project name to pattern title
const name = patternDraft.meta.title;
if (name && modalInput) {
modalInput.value = name;
}
}
}
function toggleViewStepMinimize(header) {
const card = header.closest('.view-step');
if (card) card.classList.toggle('minimized');
}
function renderPatternLibrary() {
const lib = document.getElementById('patternLibrary');
if (!lib) return;
if (!patterns.length) {
lib.innerHTML = `No saved patterns yet. Save your draft to add it here.
`;
return;
}
lib.innerHTML = patterns.map(p => {
const subtitle = p.draft?.meta?.designer || '';
return `
${p.name || 'Pattern'}
${subtitle}
`;
}).join('');
}
function savePatternDraft() {
const name = patternDraft.meta.title?.trim() || 'Pattern';
const draftCopy = JSON.parse(JSON.stringify(patternDraft));
if (!Array.isArray(patterns)) patterns = [];
const stepsForProjects = draftCopy.steps || [];
if (currentPatternId) {
const idx = patterns.findIndex(p => p.id === currentPatternId);
if (idx >= 0) {
patterns[idx] = { id: currentPatternId, name, draft: draftCopy };
} else {
const id = currentPatternId;
patterns.push({ id, name, draft: draftCopy });
}
} else {
const id = `pat-${Date.now()}`;
currentPatternId = id;
patterns.push({ id, name, draft: draftCopy });
}
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
// Propagate instructions to linked projects
let changed = false;
projects.forEach(project => {
if (project.patternId === currentPatternId && Array.isArray(project.parts)) {
project.parts.forEach((part, idx) => {
const st = stepsForProjects[idx];
if (!st) return;
part.instructions = { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] };
if (part.instructionsCollapsed === undefined) part.instructionsCollapsed = true;
changed = true;
});
}
});
if (changed) save();
renderPatternLibrary();
showAlert({ title: 'Saved', text: `"${name}" added to your basket.` });
}
function loadPatternFromLibrary(id) {
if (!Array.isArray(patterns)) patterns = [];
const found = patterns.find(p => p.id === id);
if (!found) {
showAlert({ title: 'Not found', text: 'That saved pattern was not found.' });
return;
}
try {
const draftCopy = JSON.parse(JSON.stringify(found.draft || {}));
patternDraft = normalizePatternDraft(draftCopy);
currentPatternId = found.id;
// Recompute row counters based on existing output
const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== '');
patternDraft.currentRow = Math.max(1, lines.length + 1);
// Update any linked projects to use latest instructions
let changed = false;
projects.forEach(project => {
if (project.patternId === currentPatternId && Array.isArray(project.parts)) {
(patternDraft.steps || []).forEach((st, idx) => {
if (!project.parts[idx]) return;
project.parts[idx].instructions = { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] };
project.parts[idx].instructionsCollapsed = project.parts[idx].instructionsCollapsed ?? true;
changed = true;
});
}
});
if (changed) save();
} catch (err) {
showAlert({ title: 'Load failed', text: 'Saved pattern data is invalid.' });
return;
}
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
syncPatternUI();
renderPatternLibrary();
showPatternTab('steps');
showAlert({ title: 'Loaded', text: `"${found.name}" loaded into the composer.` });
}
function deletePatternFromLibrary(id) {
patterns = patterns.filter(p => p.id !== id);
if (currentPatternId === id) currentPatternId = null;
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
renderPatternLibrary();
}
function togglePartInstructions(pId, partId) {
const project = projects.find(p => p.id === pId);
if (!project) return;
const part = project.parts.find(pt => pt.id === partId);
if (!part || !part.instructions) return;
part.instructionsCollapsed = !part.instructionsCollapsed;
save();
}
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 openAbbrevModal() {
const el = document.getElementById('abbrevModal');
if (el) {
el.classList.add('active');
renderAbbrevChecklist();
}
}
function closeAbbrevModal() {
const el = document.getElementById('abbrevModal');
if (el) el.classList.remove('active');
renderAbbrevSummary();
}
function renderAbbrevSummary() {
const el = document.getElementById('abbrevSummary');
if (!el) return;
if (patternDraft.abbrevSelection.length === 0) {
el.innerHTML = `No stitches selected.`;
return;
}
el.innerHTML = patternDraft.abbrevSelection.map(code => {
const item = getAbbrevByCode(code);
return `
${code}
${item ? item.desc : ''}
`;
}).join('');
}
function renderAbbrevChecklist() {
const listEl = document.getElementById('patternAbbrevList');
if (!listEl) return;
listEl.innerHTML = '';
const selected = new Set(patternDraft.abbrevSelection);
// Render Selected Group First
if (selected.size > 0) {
const selectedWrap = document.createElement('details');
selectedWrap.className = 'abbrev-group selected-group';
selectedWrap.open = true;
const selectedSummary = document.createElement('summary');
selectedSummary.innerHTML = `Selected (${selected.size})`;
selectedWrap.appendChild(selectedSummary);
const selectedGrid = document.createElement('div');
selectedGrid.className = 'abbrev-grid';
// Find item objects for selected codes
const library = getActiveAbbrevLibrary();
const selectedItems = library.filter(item => selected.has(item.code));
selectedItems.forEach(item => {
const pill = createAbbrevPill(item, true);
selectedGrid.appendChild(pill);
});
selectedWrap.appendChild(selectedGrid);
listEl.appendChild(selectedWrap);
}
// Render All Groups
getAbbrevGroups().forEach(bucket => {
const wrap = document.createElement('details');
wrap.className = 'abbrev-group';
// Open if user is searching, otherwise close to save space? Keep open for now.
wrap.open = false;
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 = createAbbrevPill(item, selected.has(item.code));
grid.appendChild(pill);
});
wrap.appendChild(grid);
listEl.appendChild(wrap);
});
// Add Custom Abbrev UI
const customWrap = document.createElement('div');
customWrap.className = 'abbrev-group custom-add-group';
customWrap.style.marginTop = '20px';
customWrap.innerHTML = `\n Add Custom Abbreviation
\n \n `;
listEl.appendChild(customWrap);
// Re-apply filter if text exists
filterAbbrev();
}
function addCustomAbbrev() {
const codeInp = document.getElementById('newAbbrevCode');
const descInp = document.getElementById('newAbbrevDesc');
const code = codeInp.value.trim();
const desc = descInp.value.trim();
if (!code || !desc) {
alert('Please enter both a code and a description.');
return;
}
if (!patternDraft.customAbbrev) patternDraft.customAbbrev = [];
patternDraft.customAbbrev.push({ code, desc });
// Auto-select it
if (!patternDraft.abbrevSelection.includes(code)) {
patternDraft.abbrevSelection.push(code);
}
persistPatternDraft();
updateAbbrevFromSelection();
renderAbbrevChecklist();
// Clear inputs (though re-render clears them anyway, unless we want to preserve focus?)
// Re-render clears them.
}
function createAbbrevPill(item, isSelected) {
const pill = document.createElement('div');
pill.className = 'abbrev-pill';
if (isSelected) pill.classList.add('is-selected');
pill.dataset.code = item.code;
pill.innerHTML = `
${item.code}
${item.desc}
`;
pill.addEventListener('click', () => {
const code = pill.dataset.code;
if (patternDraft.abbrevSelection.includes(code)) {
patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code);
pill.classList.remove('is-selected');
} else {
patternDraft.abbrevSelection.push(code);
pill.classList.add('is-selected');
}
updateAbbrevFromSelection();
// Don't re-render whole list to keep scroll position
});
return pill;
}
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();
renderPatternLibrary();
renderPatternView();
const savedTab = localStorage.getItem('patternActiveTab') || 'steps';
showPatternTab(savedTab);
}
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;
// --- Auth State (backend) ---
function updateAuthUI() {
const badge = document.getElementById('authStatusBadge');
const lastSync = document.getElementById('authLastSync');
const authProfile = document.querySelector('.auth-profile');
const authContent = document.querySelector('.auth-content');
const tabs = document.querySelector('.auth-tabs');
const adminTabBtn = document.getElementById('adminTabBtn');
const profileTabBtn = document.getElementById('profileTabBtn');
const displayNameInput = document.getElementById('authDisplayName');
const noteInput = document.getElementById('authNote');
const signedIn = !!auth.token;
if (badge) {
badge.textContent = signedIn ? `Signed in: ${auth.email || 'Account'}` : 'Signed out';
badge.classList.toggle('is-on', signedIn);
}
if (lastSync) {
lastSync.textContent = `Status: ${auth.status || 'unknown'}`;
}
// Default container visibility logic based on state
if (signedIn) {
// Show Profile/Admin tabs, hide Login/Signup tabs
if (tabs) tabs.style.display = 'flex';
document.querySelectorAll('.auth-tab[data-mode="login"], .auth-tab[data-mode="signup"]').forEach(el => el.style.display = 'none');
if (profileTabBtn) profileTabBtn.style.display = 'block';
if (adminTabBtn) adminTabBtn.style.display = auth.isAdmin ? 'block' : 'none';
// If we are currently in a "logged out" mode (login/signup), switch to profile
if (auth.mode === 'login' || auth.mode === 'signup') {
setAuthMode('profile');
}
} else {
// Show Login/Signup tabs, hide Profile/Admin tabs
if (tabs) tabs.style.display = 'flex';
document.querySelectorAll('.auth-tab[data-mode="login"], .auth-tab[data-mode="signup"]').forEach(el => el.style.display = 'block');
if (profileTabBtn) profileTabBtn.style.display = 'none';
if (adminTabBtn) adminTabBtn.style.display = 'none';
if (authContent) authContent.style.display = 'block';
if (authProfile) authProfile.style.display = 'none';
}
if (displayNameInput && auth.profile) displayNameInput.value = auth.profile.display_name || '';
if (noteInput && auth.profile) noteInput.value = auth.profile.note || '';
}
function setAuthMode(mode) {
auth.mode = mode;
// Update tab buttons state
document.querySelectorAll('.auth-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
// Handle Content Switching
const authContent = document.querySelector('.auth-content');
const authProfile = document.querySelector('.auth-profile');
// Hide all tab contents inside authContent first
document.querySelectorAll('.auth-tab-content').forEach(content => {
content.classList.remove('active');
});
if (mode === 'profile') {
if (authContent) authContent.style.display = 'none';
if (authProfile) authProfile.style.display = 'block';
} else if (mode === 'admin') {
if (authContent) authContent.style.display = 'block';
if (authProfile) authProfile.style.display = 'none';
const adminContent = document.getElementById('adminContent');
if (adminContent) adminContent.classList.add('active');
} else {
// Login or Signup
if (authContent) authContent.style.display = 'block';
if (authProfile) authProfile.style.display = 'none';
const target = document.getElementById(`${mode}Content`);
if (target) target.classList.add('active');
}
}
function openAuthModal() {
const overlay = document.getElementById('authOverlay');
if (!overlay) return;
overlay.classList.add('active');
// If signed in, ensure we start on profile (or last state if valid?)
// Defaulting to profile is safer.
if (auth.token) {
setAuthMode('profile');
} else {
setAuthMode('login');
}
updateAuthUI();
}
function closeAuthModal() {
const overlay = document.getElementById('authOverlay');
if (!overlay) return;
overlay.classList.remove('active');
}
async function fetchAllUsers() {
if (!auth.token || !auth.isAdmin) return;
const list = document.getElementById('allUsersList');
if (!list) return;
list.innerHTML = 'Loading...
';
try {
const resp = await fetch('/api/admin/users', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to load users');
if (!data.users || !data.users.length) {
list.innerHTML = 'No users found.
';
return;
}
list.innerHTML = data.users.map(u => `
${u.email}
${u.display_name || 'No name'} • ${u.status} ${u.is_admin ? '• Admin' : ''}
${!u.is_admin ? `` : ''}
${u.status === 'active' ? `` : ''}
${u.status === 'suspended' ? `` : ''}
`).join('');
} catch (err) {
list.innerHTML = `Error: ${err.message}
`;
}
}
window.fetchAllUsers = fetchAllUsers;
async function submitAuth(event, mode) {
if (event) event.preventDefault();
const email = document.getElementById(`${mode}Email`).value;
const password = document.getElementById(`${mode}Password`).value;
if (!email || !password) {
showAlert({ title: 'Missing Info', text: 'Please enter both email and password.' });
return false;
}
if (mode === 'signup') {
const confirmPassword = document.getElementById('signupConfirmPassword').value;
if (password !== confirmPassword) {
showAlert({ title: 'Passwords Do Not Match', text: 'Please re-enter your passwords.' });
return false;
}
}
try {
const endpoint = mode === 'signup' ? '/api/signup' : '/api/login';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Auth failed', text: data.error || 'Invalid credentials' });
return false;
}
auth = { token: data.token, email: data.email, isAdmin: !!data.is_admin, status: data.status || 'active', mode: 'login' };
localStorage.setItem('authToken', auth.token);
updateAuthUI();
closeAuthModal();
await fetchProfile();
} catch (err) {
showAlert({ title: 'Auth failed', text: err.message });
}
return false;
}
async function autoSync() {
await fetchProfile();
}
async function saveProfile(event) {
if (event) event.preventDefault();
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
const note = (document.getElementById('authNote') || {}).value || '';
try {
await fetch('/api/me', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${auth.token}` },
body: JSON.stringify({ displayName, note })
});
await fetchProfile();
showAlert({ title: 'Profile saved' });
} catch (err) {
showAlert({ title: 'Profile save failed', text: err.message });
}
return false;
}
async function fetchProfile() {
if (!auth.token) return;
const resp = await fetch('/api/me', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Profile fetch failed', text: data.error || 'Error' });
return;
}
auth.profile = data.profile;
auth.isAdmin = !!data.profile.is_admin;
auth.status = data.profile.status || 'active';
document.getElementById('authDisplayName').value = data.profile.display_name || '';
document.getElementById('authNote').value = data.profile.note || '';
updateAuthUI();
}
async function logoutAuth() {
try {
await fetch('/api/logout', { method: 'POST', headers: { Authorization: `Bearer ${auth.token}` } });
} catch (e) {}
auth = { token: '', email: '', isAdmin: false, status: 'unknown' };
localStorage.removeItem('authToken');
updateAuthUI();
closeAuthModal();
}
// --- 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();
// Initialize auth UI
updateAuthUI();
// expose auth helpers
window.openAuthModal = openAuthModal;
window.closeAuthModal = closeAuthModal;
window.setAuthMode = setAuthMode;
window.submitAuth = submitAuth;
window.autoSync = autoSync;
window.saveProfile = saveProfile;
window.logoutAuth = logoutAuth;
window.savePatternDraft = savePatternDraft;
window.loadPatternFromLibrary = loadPatternFromLibrary;
window.deletePatternFromLibrary = deletePatternFromLibrary;
window.sharePattern = sharePattern;
window.togglePartInstructions = togglePartInstructions;
window.fetchPendingUsers = fetchPendingUsers;
window.downloadBackup = downloadBackup;
window.uploadRestore = uploadRestore;
window.setAuthMode = setAuthMode;
window.approveUser = approveUser;
window.suspendUser = suspendUser;
window.makeAdmin = makeAdmin;
// --- 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 = `
${title}
${text}
`;
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 = `
`;
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 = '';
} else {
document.body.classList.remove('dark-mode');
document.getElementById('themeBtn').innerHTML = '';
}
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 ? '' : '';
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 => `
`).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 = '' + patterns.map(p => ``).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 = `
${p.name}
`;
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; // 5vh–60vh
const scale = 0.9 + Math.random() * 0.4;
const duration = 12 + Math.random() * 8; // 12–20s
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; // 5vh–60vh
const scale = 0.85 + Math.random() * 0.4;
const duration = 14 + Math.random() * 8; // 14–22s
const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-8–24deg
const sway = 4 + Math.random() * 6; // px
const flipDur = 5 + Math.random() * 4; // 5–9s
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; // 10–20s
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;
const patternName = project ? `${project.name}` : 'Pattern';
// If a different pattern is loaded, warn before overwriting the active draft
if (currentPatternId) {
showConfirm({
title: 'Overwrite current draft?',
text: 'Creating a pattern from this project will replace the draft currently in the composer.',
confirmText: 'Replace draft',
cancelText: 'Cancel',
danger: false
}).then(ok => {
if (ok) buildPatternFromProject(project, patternName);
});
return;
}
buildPatternFromProject(project, patternName);
}
function buildPatternFromProject(project, patternName) {
// build patternDraft from project parts
const steps = (project.parts || []).map((part, idx) => ({
title: part.name || `Part ${idx + 1}`,
rows: [],
rowDraft: '',
note: part.note || '',
image: ''
}));
const newDraft = normalizePatternDraft({
mode: 'crochet',
meta: { title: patternName, designer: '' },
materials: '',
gauge: '',
gaugeSts: '',
gaugeRows: '',
gaugeHook: '',
size: '',
abbrev: patternDraft.abbrev,
abbrevSelection: patternDraft.abbrevSelection || [],
stitches: '',
notes: project.note || '',
steps
});
patternDraft = newDraft;
currentPatternId = null;
persistPatternDraft();
syncPatternUI();
renderPatternLibrary();
showPatternTab('steps');
showAlert({ title: 'Pattern created', text: `"${patternName}" is ready in the composer.` });
}
// --- 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: [], patternId: null };
const selectedPatternId = patternSelect ? patternSelect.value : '';
const chosenPattern = selectedPatternId ? patterns.find(p => String(p.id) === selectedPatternId) : null;
if (chosenPattern && chosenPattern.draft && Array.isArray(chosenPattern.draft.steps) && chosenPattern.draft.steps.length) {
newProject.patternId = chosenPattern.id;
chosenPattern.draft.steps.forEach((st, idx) => {
newProject.parts.push({
id: Date.now() + idx + 1,
name: st.title || `Step ${idx + 1}`,
count: 0,
locked: false,
finished: false,
minimized: false,
max: null,
color: nextColor,
note: '',
instructionsCollapsed: true,
instructions: { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] }
});
});
} else {
newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '', instructionsCollapsed: true, instructions: null });
}
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: '', instructionsCollapsed: true, instructions: null });
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 = 'Toadstools & twine await...
Tap + to begin a new project.
';
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 ? '' : '';
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
? ``
: `
`;
const countSubtext = part.minimized ? '' : `
${part.max !== null ? `${part.count} / ${part.max}` : 'No max set'}
`;
partsHtml += `
${part.count}
${countSubtext}
${part.instructions ? `
Pattern
${part.instructions.title || ''}
${(part.instructions.rows || []).map((r,i)=>`- Row ${i+1}: ${r}
`).join('') || '- No rows
'}
` : ''}
`;
});
const projectContainer = document.createElement('div');
projectContainer.className = `project-container ${projectCollapsedClass}`;
projectContainer.style = `--project-color: ${project.color}`;
const projectNoteId = `project-note-${project.id}`;
projectContainer.innerHTML = `
${partsHtml}
`;
grid.appendChild(projectContainer);
});
lastCountPulse = null;
lastFinishedId = null;
app.appendChild(grid);
}
render();
if (auth.token) {
fetchProfile().catch(()=>{});
}
function flashSave() {
const el = document.getElementById('patternSaveIndicator');
const mini = document.getElementById('patternSaveIndicatorMini');
if (!el) return;
el.textContent = 'Saved';
el.style.opacity = '1';
if (mini) mini.textContent = 'Saved';
if (saveFlashTimer) clearTimeout(saveFlashTimer);
saveFlashTimer = setTimeout(() => {
el.style.opacity = '0.6';
}, 1200);
}
function sharePattern() {
try {
const payload = { patternDraft };
const json = JSON.stringify(payload);
const b64 = btoa(unescape(encodeURIComponent(json)));
const base = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}`;
const url = `${base}pattern-viewer.html?data=${encodeURIComponent(b64)}`;
navigator.clipboard?.writeText(url);
showAlert({ title: 'Share link ready', text: 'Link copied to clipboard. Open on any device to view checklist.' });
} catch (err) {
showAlert({ title: 'Share failed', text: err.message });
}
}
async function fetchPendingUsers() {
if (!auth.token || !auth.isAdmin) return;
const resp = await fetch('/api/admin/users/pending', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Failed', text: data.error || 'Could not load pending users' });
return;
}
pendingUsers = data.users || [];
const list = document.getElementById('pendingList');
if (list) {
list.innerHTML = pendingUsers.map(u => `
${u.email}
${u.display_name || ''}
`).join('') || 'No pending users.
';
}
}
async function updateUserStatus(id, status) {
const resp = await fetch(`/api/admin/users/${id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ status })
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Failed', text: data.error || 'Update failed' });
} else {
fetchPendingUsers();
}
}
async function approveUser(id) { return updateUserStatus(id, 'active'); }
async function suspendUser(id) { return updateUserStatus(id, 'suspended'); }
async function makeAdmin(id) {
const resp = await fetch(`/api/admin/users/${id}/admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ is_admin: true })
});
const data = await resp.json();
if (!resp.ok) showAlert({ title: 'Failed', text: data.error || 'Admin update failed' });
else fetchPendingUsers();
}
async function downloadBackup() {
const resp = await fetch('/api/admin/backup', { headers: { Authorization: `Bearer ${auth.token}` } });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showAlert({ title: 'Backup failed', text: err.error || 'Server error' });
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `toadstool_dump_${new Date().toISOString().split('T')[0]}.sql`;
a.click();
URL.revokeObjectURL(url);
}
async function uploadRestore(event) {
const file = event.target.files[0];
if (!file) return;
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
const resp = await fetch('/api/admin/restore-sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ sql: base64 })
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Restore failed', text: data.error || data.message || 'Error' });
} else {
showAlert({ title: 'Restore complete', text: data.message || 'Database restored.' });
}
} catch (err) {
showAlert({ title: 'Restore failed', text: err.message });
} finally {
event.target.value = '';
}
}