// --- 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 escapeHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function normalizePatternDraft(d = {}) { const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '', collapsed: false, rowDone: {} }; const base = { mode: 'crochet', currentRow: 1, line: '', output: '', meta: { title: '', designer: '', tags: [] }, materials: '', gauge: '', gaugeSts: '', gaugeRows: '', size: '', abbrev: '', abbrevSelection: [], customAbbrev: [], stitches: '', notes: '', steps: [], palette: [], yarns: [], hooks: [], finishedPhotos: [], patternFile: null, previewSize: 'full', savedAt: '', sourceId: '' }; const merged = { ...base, ...d }; merged.meta = { ...base.meta, ...(d.meta || {}) }; merged.meta.tags = Array.isArray(merged.meta.tags) ? merged.meta.tags : []; 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 : []; merged.finishedPhotos = Array.isArray(merged.finishedPhotos) ? merged.finishedPhotos : []; // 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 || ''; merged.savedAt = merged.savedAt || ''; merged.sourceId = merged.sourceId || ''; 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 = patternDraft.sourceId || null; let referenceFile = JSON.parse(localStorage.getItem('crochetReferenceFile') || 'null'); let activePartRef = JSON.parse(localStorage.getItem('activePartRef') || 'null'); // { projectId, partId } — the part whose count is shown in the viewer counter bar function resolveImageUrl(url) { if (!url) return ''; if (/^https?:\/\//i.test(url) || url.startsWith('data:')) return url; if (url.startsWith('/uploads/')) { const apiBase = localStorage.getItem('apiBase') || window.location.origin; return `${apiBase}${url}`; } return url; } function normalizeProjectItem(item = {}, index = 0) { const data = item.data || item; const id = data.id ?? item.id ?? `proj-${Date.now()}-${index}`; const rawName = data.name || item.name || ''; const safeName = rawName && rawName !== 'undefined' ? rawName : `Project ${index + 1}`; const name = safeName.trim(); const parts = Array.isArray(data.parts) ? data.parts : []; return { ...data, id, name, parts, collapsed: !!data.collapsed, note: data.note || '', deleted_at: data.deleted_at ?? item.deleted_at ?? null }; } function normalizeProjects(items = []) { if (!Array.isArray(items)) return []; return items.map((item, idx) => normalizeProjectItem(item, idx)); } function normalizePatternLibraryItem(item = {}) { const draft = normalizePatternDraft(item.draft || item.data?.draft || item.data || {}); const name = item.name || item.title || draft.meta?.title || 'Pattern'; return { id: item.id || `pat-${Date.now()}`, name, title: item.title || name, source: item.source || 'composed', patternFile: item.patternFile || null, draft, data: item.data || { draft }, slug: item.slug || null, deleted_at: item.deleted_at || null, updated_at: item.updated_at || null }; } function normalizePatternLibrary(items = []) { if (!Array.isArray(items)) return []; return items.map(normalizePatternLibraryItem); } function findProjectById(pId) { return projects.find(p => String(p.id) === String(pId)); } function findPartById(project, partId) { if (!project || !Array.isArray(project.parts)) return null; return project.parts.find(pt => String(pt.id) === String(partId)); } function findPatternByName(name, excludeId = null) { const key = (name || '').trim().toLowerCase(); if (!key) return null; return patterns.find(p => { if (excludeId && p.id === excludeId) return false; if (p.deleted_at) return false; const title = (p.name || p.title || p.draft?.meta?.title || '').trim().toLowerCase(); return title === key; }) || null; } function dedupePatternsByName(patternList = []) { const seen = new Map(); const now = new Date().toISOString(); patternList.forEach(p => { if (p.deleted_at) return; const name = (p.name || p.title || p.draft?.meta?.title || 'Pattern').trim(); const key = name.toLowerCase(); const savedAt = p.draft?.savedAt || p.updated_at || ''; const ts = savedAt ? new Date(savedAt).getTime() : 0; const existing = seen.get(key); if (!existing || ts > existing.ts) { if (existing && existing.item) { existing.item.deleted_at = now; } seen.set(key, { item: p, ts }); } else { p.deleted_at = now; } p.name = name; p.title = name; if (p.draft && p.draft.meta) p.draft.meta.title = name; if (p.data && p.data.draft && p.data.draft.meta) p.data.draft.meta.title = name; }); return patternList; } function isPatternDraftEmpty(draft) { if (!draft) return true; const hasTitle = draft.meta?.title && draft.meta.title.trim(); const hasSteps = Array.isArray(draft.steps) && draft.steps.length > 0; const hasOutput = draft.output && draft.output.trim(); const hasNotes = draft.notes && draft.notes.trim(); return !(hasTitle || hasSteps || hasOutput || hasNotes); } function getMostRecentPatternId() { let latestId = null; let latestTime = 0; patterns.forEach(p => { const draft = p.draft || p.data?.draft || {}; const savedAt = draft.savedAt || p.updated_at || ''; const ts = savedAt ? new Date(savedAt).getTime() : 0; if (ts > latestTime) { latestTime = ts; latestId = p.id; } }); return latestId; } function applyPatternFromLibrary(id, options = {}) { const { silent = false } = options; if (!Array.isArray(patterns)) patterns = []; const found = patterns.find(p => p.id === id); if (!found) { if (!silent) showAlert({ title: 'Not found', text: 'That saved pattern was not found.' }); return false; } try { const draftCopy = JSON.parse(JSON.stringify(found.draft || found.data?.draft || {})); patternDraft = normalizePatternDraft(draftCopy); currentPatternId = found.id; patternDraft.sourceId = found.id; const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== ''); patternDraft.currentRow = Math.max(1, lines.length + 1); 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 || [], note: st.note || '', image: st.image || '' }; project.parts[idx].instructionsCollapsed = project.parts[idx].instructionsCollapsed ?? true; changed = true; }); } }); if (changed) save(); } catch (err) { if (!silent) showAlert({ title: 'Load failed', text: 'Saved pattern data is invalid.' }); return false; } localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft)); syncPatternUI(); renderPatternLibrary(); showPatternTab('steps'); if (!silent) showAlert({ title: 'Loaded', text: `"${found.name}" loaded into the composer.` }); return true; } function autoLoadRecentPattern() { if (!auth.token || !patterns.length) return; if (currentPatternId || !isPatternDraftEmpty(patternDraft)) return; const recentId = getMostRecentPatternId(); if (!recentId) return; applyPatternFromLibrary(recentId, { silent: true }); } function backfillPatternInstructions() { if (!Array.isArray(projects) || !Array.isArray(patterns)) return false; const patternsById = new Map(patterns.map(p => [String(p.id), p])); let changed = false; projects.forEach(project => { if (!project.patternId || !Array.isArray(project.parts)) return; const pattern = patternsById.get(String(project.patternId)); const draft = pattern?.draft || pattern?.data?.draft; if (!draft || !Array.isArray(draft.steps)) return; project.parts.forEach((part, idx) => { const st = draft.steps[idx]; if (!st) return; const existing = part.instructions || {}; const next = { title: existing.title || st.title || `Step ${idx + 1}`, rows: Array.isArray(existing.rows) && existing.rows.length ? existing.rows : (st.rows || []), note: existing.note || st.note || '', image: existing.image || st.image || '' }; if (!part.instructions || JSON.stringify(existing) !== JSON.stringify(next)) { part.instructions = next; part.instructionsCollapsed = part.instructionsCollapsed ?? true; changed = true; } }); }); if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); } return changed; } projects = normalizeProjects(projects); localStorage.setItem('crochetCounters', JSON.stringify(projects)); patterns = normalizePatternLibrary(patterns); patterns = dedupePatternsByName(patterns); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); backfillPatternInstructions(); autoLoadRecentPattern(); // 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 importSelection = document.getElementById('importSelection'); const importList = document.getElementById('importList'); const settingsOverlay = document.getElementById('settingsOverlay'); const settingDarkMode = document.getElementById('settingDarkMode'); const settingAnimations = document.getElementById('settingAnimations'); const patternFab = document.getElementById('patternFab'); const syncBanner = document.getElementById('syncBanner'); const patternPicker = document.getElementById('patternPicker'); const patternSelect = document.getElementById('patternSelect'); const patternOverlay = document.getElementById('patternOverlay'); const patternSearch = document.getElementById('patternSearch'); const patternSort = document.getElementById('patternSort'); 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(); }); } if (settingsOverlay) { settingsOverlay.addEventListener('click', (e) => { if (e.target === settingsOverlay) closeSettingsModal(); }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && patternOverlay && patternOverlay.classList.contains('active')) { closePatternComposer(); } if (e.key === 'Escape' && settingsOverlay && settingsOverlay.classList.contains('active')) { closeSettingsModal(); } 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(); updateTabDots(); } 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(); }); const tagInputEl = document.getElementById('patternTagInput'); if (tagInputEl) { tagInputEl.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const val = tagInputEl.value.trim().replace(/,$/, ''); if (val && !patternDraft.meta.tags.includes(val)) { patternDraft.meta.tags.push(val); persistPatternDraft(); renderTagPills(); } tagInputEl.value = ''; } else if (e.key === 'Backspace' && tagInputEl.value === '' && patternDraft.meta.tags.length > 0) { patternDraft.meta.tags.pop(); persistPatternDraft(); renderTagPills(); } }); } } function renderTagPills() { const list = document.getElementById('patternTagList'); if (!list) return; if (!Array.isArray(patternDraft.meta.tags)) patternDraft.meta.tags = []; list.innerHTML = patternDraft.meta.tags.map((tag, i) => `${escapeHtml(tag)}` ).join(''); } function removePatternTag(idx) { if (!Array.isArray(patternDraft.meta.tags)) return; patternDraft.meta.tags.splice(idx, 1); persistPatternDraft(); renderTagPills(); } 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(); renderFinishedPhotoList(); renderTagPills(); updateComposerFileButtons(); updateRefFab(); renderSteps(); renderAbbrevSummary(); updatePatternHeaderTitle(); updateTabDots(); } function renderFinishedPhotoList() { const el = document.getElementById('finishedPhotoList'); if (!el) return; if (!Array.isArray(patternDraft.finishedPhotos)) patternDraft.finishedPhotos = []; if (patternDraft.finishedPhotos.length === 0) { el.innerHTML = `

No finished photos yet.

`; return; } el.innerHTML = patternDraft.finishedPhotos.map((photo, idx) => `
${auth.token ? ` ${photo.url ? `` : ''} ${photo.url ? 'Replace or remove.' : 'Upload a finished photo.'} ` : 'Sign in to upload photos.'}
${photo.url ? `
Finished photo
` : ''} `).join(''); } function addFinishedPhoto() { if (!Array.isArray(patternDraft.finishedPhotos)) patternDraft.finishedPhotos = []; patternDraft.finishedPhotos.push({ url: '' }); persistPatternDraft(); renderFinishedPhotoList(); } function updateFinishedPhoto(idx, value) { if (!patternDraft.finishedPhotos[idx]) return; patternDraft.finishedPhotos[idx].url = value; persistPatternDraft(); renderFinishedPhotoList(); } function removeFinishedPhoto(idx) { if (!Array.isArray(patternDraft.finishedPhotos)) return; patternDraft.finishedPhotos.splice(idx, 1); persistPatternDraft(); renderFinishedPhotoList(); } function uploadFinishedPhoto(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) { const resolvedUrl = resolveImageUrl(data.url); if (!patternDraft.finishedPhotos[idx]) patternDraft.finishedPhotos[idx] = { url: '' }; patternDraft.finishedPhotos[idx].url = resolvedUrl; persistPatternDraft(); renderFinishedPhotoList(); showAlert({ title: 'Upload success', text: 'Photo added.' }); } else { showAlert({ title: 'Upload failed', text: data.error || 'Error' }); } } catch (err) { showAlert({ title: 'Error', text: err.message }); } }; input.click(); } 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) => `
Yarn ${String.fromCharCode(65 + idx)}
${yarnWeights.map(w => ` `).join('')}
`).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('.available-panel .abbrev-pill'); items.forEach(el => { const text = el.dataset.lookup || ''; el.style.display = text.includes(term) ? 'flex' : 'none'; }); // Also hide empty groups document.querySelectorAll('.available-panel .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(); } async function clearPatternOutput() { const ok = await showConfirm({ title: 'Clear draft?', text: 'This will remove all draft fields, steps, and notes.', confirmText: 'Clear', cancelText: 'Cancel', danger: true }); if (!ok) 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.abbrevSelection = []; patternDraft.customAbbrev = []; patternDraft.stitches = ''; patternDraft.notes = ''; patternDraft.steps = []; patternDraft.palette = []; patternDraft.yarns = []; patternDraft.hooks = []; patternDraft.savedAt = ''; patternDraft.sourceId = ''; 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 json = JSON.stringify(payload, null, 2); const url = `data:application/json;charset=utf-8,${encodeURIComponent(json)}`; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // data: URI avoids object URL timing issues that can create 0-byte downloads } 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(); } async function exportPatternPDF() { const w = window.open('', '_blank'); if (!w) { showAlert({ title: 'Popup blocked', text: 'Please allow popups to export the PDF.' }); return; } const printFriendly = await showConfirm({ title: 'Export theme', text: 'Use a print-friendly light theme?', confirmText: 'Print-friendly', cancelText: 'App-themed' }); if (w.closed) return; const styles = getComputedStyle(document.body); const themeBg = printFriendly ? '#ffffff' : (styles.getPropertyValue('--bg') || '#f4f0e8'); const themeText = printFriendly ? '#111111' : (styles.getPropertyValue('--text') || '#2f2b28'); const accent = printFriendly ? '#222222' : (styles.getPropertyValue('--project-color') || '#7a8c6a'); const gaugeBlock = `${patternDraft.gaugeSts || ''}${patternDraft.gaugeRows ? ' • ' + patternDraft.gaugeRows : ''}${patternDraft.gaugeHook ? ' • ' + patternDraft.gaugeHook : ''}`; const assetBase = window.location.origin; const mushroomUrl = `${assetBase}/assets/textures/mushroom.svg`; const weightLabels = new Map((yarnWeights || []).map(w => { const val = String(w.val); const label = w.label ? `${w.label} (${val})` : `Weight ${val}`; return [val, label]; })); const yarnItems = (patternDraft.yarns || []).map(y => { const weightKey = y.weight !== undefined && y.weight !== null ? String(y.weight) : ''; const weightLabel = weightLabels.get(weightKey) || (weightKey ? `Weight ${weightKey}` : ''); const swatch = y.color ? `` : ''; return `
  • ${y.note || 'Yarn'}${weightLabel ? ` — ${weightLabel}` : ''}${swatch}
  • `; }); const hookItems = (patternDraft.hooks || []).map(h => `
  • ${h.size || ''}${h.note ? ` (${h.note})` : ''}
  • `); const paletteItems = (patternDraft.palette || []).map(c => `
  • `); const customMap = new Map((patternDraft.customAbbrev || []).map(item => [item.code, item.desc])); const selected = (patternDraft.abbrevSelection || []).map(code => { const base = getAbbrevByCode(code); const desc = customMap.get(code) || base?.desc || ''; return desc ? `${code} - ${desc}` : code; }).join('\n'); const abbrevBlock = (patternDraft.abbrev || '').trim() ? patternDraft.abbrev : selected; const fontLink = ''; const sections = []; if ((patternDraft.materials || '').trim()) { sections.push(`
    Materials
    ${patternDraft.materials}
    `); } if (yarnItems.length || hookItems.length || paletteItems.length) { const parts = []; if (yarnItems.length) parts.push(``); if (hookItems.length) parts.push(``); if (paletteItems.length) parts.push(``); sections.push(`
    Yarns, Hooks, Palette
    ${parts.join('')}
    `); } if (gaugeBlock.trim() || (patternDraft.gauge || '').trim() || (patternDraft.size || '').trim()) { sections.push(`
    Gauge / Size
    ${[gaugeBlock, patternDraft.gauge || '', patternDraft.size || ''].filter(Boolean).join('\n')}
    `); } if (patternDraft.finishedPhotos && patternDraft.finishedPhotos.length) { sections.push(`
    Finished Photos
    ${(patternDraft.finishedPhotos || []).map(p => ``).join('')}
    `); } if ((abbrevBlock || '').trim()) { sections.push(`
    Abbreviations
    ${abbrevBlock}
    `); } if ((patternDraft.stitches || '').trim()) { sections.push(`
    Stitch Guide
    ${patternDraft.stitches}
    `); } if ((patternDraft.steps || []).length) { sections.push(`
    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 || 'No rows yet.'}${note}${img}
    `; }).join('
    ')}
    `); } if ((patternDraft.output || '').trim()) { sections.push(`
    Rows
    ${patternDraft.output}
    `); } if ((patternDraft.notes || '').trim()) { sections.push(`
    Notes
    ${patternDraft.notes}
    `); } const html = ` ${patternDraft.meta.title || 'Pattern'} - PDF ${fontLink}

    ${patternDraft.meta.title || 'Pattern'}

    ${patternDraft.meta.designer ? `

    ${patternDraft.meta.designer}

    ` : ''} ${sections.join('')} `; 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); }); if (tab === 'view') { renderPatternView(); } if (tab === 'library') { renderPatternLibrary(); } updateTabDots(); 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(); if (field === 'title') updatePatternHeaderTitle(); } function updatePatternField(field, value) { patternDraft[field] = value; persistPatternDraft(); } function renderSteps() { const container = document.getElementById('patternSteps'); if (!container) return; container.innerHTML = ''; if (patternDraft.steps.length === 0) { container.innerHTML = `

    Start your pattern

    Pick a template to begin.

    ${Object.entries(patternTemplates).map(([key, tpl]) => `
    ${tpl.icon} ${tpl.name} ${tpl.desc}
    `).join('')}
    `; return; } patternDraft.steps.forEach((step, idx) => { const card = document.createElement('div'); const isCollapsed = !!step.collapsed; card.className = `pattern-step-card card-pop ${isCollapsed ? 'is-collapsed' : ''}`; const rows = step.rows || []; const rowCount = rows.length; const stepImageUrl = resolveImageUrl(step.image || ''); const rowList = rows.map((r, i) => `
    ${i + 1}.
    `).join(''); card.innerHTML = `
    Step ${idx + 1} ${rowCount} row${rowCount === 1 ? '' : 's'}
    ${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 toggleStepCollapse(idx) { if (!patternDraft.steps[idx]) return; patternDraft.steps[idx].collapsed = !patternDraft.steps[idx].collapsed; persistPatternDraft(); renderSteps(); } 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) { const resolvedUrl = resolveImageUrl(data.url); patternDraft.steps[idx].image = resolvedUrl; 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.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.

    ' : ''}
    ${patternDraft.finishedPhotos.length ? `

    Finished Photos

    ${patternDraft.finishedPhotos.map(p => `Finished photo`).join('')}
    ` : ''}

    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) => `
    Step ${i + 1}${st.title ? ': ' + st.title : ''}
      ${(st.rows || []).map((r, idx) => { const rowDone = st.rowDone && st.rowDone[idx]; return `
    • Row ${idx + 1}: ${r}
    • `; }).join('') || '
    • No rows yet.
    • '}
    ${st.image ? `Step Image` : ''}
    `).join('')}
    `; container.querySelectorAll('.view-row').forEach(row => { row.addEventListener('click', (e) => { const stepIdx = Number(e.currentTarget.dataset.viewStep); const rowIdx = Number(e.currentTarget.dataset.viewRow); const step = patternDraft.steps[stepIdx]; if (!step) return; if (!step.rowDone) step.rowDone = {}; step.rowDone[rowIdx] = !step.rowDone[rowIdx]; persistPatternDraft(); e.currentTarget.classList.toggle('is-done', step.rowDone[rowIdx]); }); }); 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(options = {}) { const { skipSave = false } = options; if (!skipSave && !currentPatternId) { showAlert({ title: 'Save required', text: 'Save this pattern to your shelf before starting a project.' }); return; } 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 savePatternAndStartProject() { savePatternDraft({ silent: true }); startProjectFromDraft({ skipSave: true }); } function toggleViewStepMinimize(header) { const card = header.closest('.view-step'); if (card) card.classList.toggle('minimized'); } function openImagePreview(url) { if (!url) return; let overlay = document.getElementById('imagePreviewOverlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'imagePreviewOverlay'; overlay.className = 'image-preview-overlay'; overlay.innerHTML = `
    Preview
    `; document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('active'); }); overlay.querySelector('.image-preview-close').addEventListener('click', () => { overlay.classList.remove('active'); }); } const img = overlay.querySelector('.image-preview-img'); if (img) img.src = url; overlay.classList.add('active'); } let shelfActiveFilter = 'all'; function setShelfFilter(filter) { shelfActiveFilter = filter; document.querySelectorAll('.shelf-filter-chip[data-filter]').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === filter); }); renderPatternLibrary(); } function renderShelfTagFilters() { const container = document.getElementById('shelfTagFilters'); if (!container) return; const allTags = new Set(); patterns.filter(p => !p.deleted_at).forEach(p => { const tags = p.draft?.meta?.tags || p.data?.draft?.meta?.tags || []; tags.forEach(t => allTags.add(t)); }); container.innerHTML = [...allTags].sort().map(tag => `` ).join(''); } function renderPatternLibrary() { renderShelfTagFilters(); updateRefFab(); const lib = document.getElementById('patternLibrary'); if (!lib) return; const visiblePatterns = patterns.filter(p => !p.deleted_at); if (!visiblePatterns.length) { lib.innerHTML = `

    Your shelf is empty

    Save a composed pattern or upload a PDF, EPUB, or image to get started.

    `; return; } const term = (patternSearch?.value || '').trim().toLowerCase(); const sortMode = patternSort?.value || 'recent'; const list = visiblePatterns .map(p => { const draft = p.draft || p.data?.draft || {}; return { ...p, draft }; }) .filter(p => { // Text search: title, designer, tags if (term) { const title = (p.name || p.title || p.draft?.meta?.title || '').toLowerCase(); const designer = (p.draft?.meta?.designer || '').toLowerCase(); const tags = (p.draft?.meta?.tags || []).join(' ').toLowerCase(); if (!title.includes(term) && !designer.includes(term) && !tags.includes(term)) return false; } // Filter chips if (shelfActiveFilter === 'all') return true; if (shelfActiveFilter === 'crochet') return (p.draft?.mode || 'crochet') === 'crochet'; if (shelfActiveFilter === 'knit') return p.draft?.mode === 'knit'; if (shelfActiveFilter === 'pdf') return (p.patternFile?.type || p.draft?.patternFile?.type) === 'pdf'; if (shelfActiveFilter === 'epub') return (p.patternFile?.type || p.draft?.patternFile?.type) === 'epub'; if (shelfActiveFilter.startsWith('tag:')) { const tag = shelfActiveFilter.slice(4); return (p.draft?.meta?.tags || []).includes(tag); } return true; }) .sort((a, b) => { if (sortMode === 'name') { const an = (a.name || a.title || '').toLowerCase(); const bn = (b.name || b.title || '').toLowerCase(); return an.localeCompare(bn); } const at = new Date(a.draft?.savedAt || a.updated_at || 0).getTime(); const bt = new Date(b.draft?.savedAt || b.updated_at || 0).getTime(); return bt - at; }); if (!list.length) { lib.innerHTML = `

    No matches found.

    `; return; } lib.innerHTML = list.map(p => { const title = p.name || p.title || p.draft?.meta?.title || 'Pattern'; const source = p.source || 'composed'; const patFile = p.patternFile || p.draft?.patternFile; const coverImg = p.draft?.meta?.coverImage; const designer = p.draft?.meta?.designer || ''; const dateStr = p.updated_at || p.draft?.savedAt || ''; const dateFmt = dateStr ? new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : ''; // Thumbnail let thumbInner; if (source === 'upload' && patFile?.type === 'image' && patFile?.url) { thumbInner = ``; } else if (coverImg) { thumbInner = ``; } else if (source === 'upload') { const icon = patFile?.type === 'pdf' ? 'fa-file-pdf' : patFile?.type === 'epub' ? 'fa-book' : 'fa-file-image'; thumbInner = ``; } else { thumbInner = ``; } const thumbIconClass = (source === 'upload' && patFile?.type !== 'image' && !coverImg) ? ' pat-thumb--icon' : ''; // Type badge const badgeType = source === 'upload' ? (patFile?.type || 'file') : 'composed'; const badgeLabel = { pdf: 'PDF', epub: 'EPUB', image: 'Image', file: 'File', composed: 'Composed' }[badgeType] || 'Pattern'; // Action buttons const hasFile = !!(patFile?.url); const viewBtn = hasFile ? `` : ''; const loadBtn = source === 'composed' ? `` : ''; const startBtn = ``; const deleteBtn = ``; const tags = p.draft?.meta?.tags || []; const tagsHtml = tags.length ? `
    ${tags.map(t => `#${t}`).join('')}
    ` : ''; return `
    ${thumbInner}
    ${badgeLabel}
    ${title}
    ${designer ? `
    ${designer}
    ` : ''} ${tagsHtml} ${dateFmt ? `
    ${dateFmt}
    ` : ''}
    ${viewBtn}${loadBtn}${startBtn}${deleteBtn}
    `; }).join(''); } function savePatternDraft(options = {}) { const { silent = false } = options; if (!patternDraft.meta.title?.trim()) { showPatternTab('info'); // Expand the Basic Details card if collapsed const basicCard = document.getElementById('specCard-basic'); if (basicCard) basicCard.classList.remove('is-collapsed'); const titleEl = document.getElementById('patternTitle'); if (titleEl) { titleEl.focus(); titleEl.classList.add('field-error'); setTimeout(() => titleEl.classList.remove('field-error'), 1500); } if (!silent) showAlert({ title: 'Title required', text: 'Give your pattern a name before saving.' }); return; } const originalName = patternDraft.meta.title?.trim() || 'Pattern'; const name = originalName; const draftCopy = JSON.parse(JSON.stringify(patternDraft)); if (!Array.isArray(patterns)) patterns = []; const stepsForProjects = draftCopy.steps || []; if (!currentPatternId && patternDraft.sourceId) { const match = patterns.find(p => p.id === patternDraft.sourceId); if (match) currentPatternId = match.id; } const existingByName = findPatternByName(name, currentPatternId); if (existingByName && !currentPatternId) { currentPatternId = existingByName.id; } if (existingByName && currentPatternId && existingByName.id !== currentPatternId) { if (!silent) { showAlert({ title: 'Name already used', text: 'Choose a different pattern name to avoid duplicates.' }); } return; } if (currentPatternId) { const idx = patterns.findIndex(p => p.id === currentPatternId); if (idx >= 0) { patterns[idx] = { ...patterns[idx], id: currentPatternId, name, title: name, draft: draftCopy, data: { draft: draftCopy }, deleted_at: null }; } else { const id = currentPatternId; patterns.push({ id, name, title: name, draft: draftCopy, data: { draft: draftCopy }, deleted_at: null }); } } else { const id = `pat-${Date.now()}`; currentPatternId = id; patternDraft.sourceId = id; patterns.push({ id, name, title: name, draft: draftCopy, data: { draft: draftCopy }, deleted_at: null }); } patterns = dedupePatternsByName(patterns); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); patternDraft.savedAt = new Date().toISOString(); patternDraft.sourceId = currentPatternId || patternDraft.sourceId || ''; persistPatternDraft(); // 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 || [], note: st.note || '', image: st.image || '' }; if (part.instructionsCollapsed === undefined) part.instructionsCollapsed = true; changed = true; }); } }); if (changed) save(); renderPatternLibrary(); populatePatternSelect(); if (!silent) { showAlert({ title: 'Saved', text: `"${name}" added to your shelf.` }); } } function loadPatternFromLibrary(id) { applyPatternFromLibrary(id); } function startProjectFromLibrary(id) { const loaded = applyPatternFromLibrary(id, { silent: true }); if (!loaded) return; startProjectFromDraft({ skipSave: true }); } function sharePatternFromLibrary(id) { const loaded = applyPatternFromLibrary(id, { silent: true }); if (!loaded) return; sharePattern(); } function deletePatternFromLibrary(id) { const target = patterns.find(p => p.id === id); if (!target) return; target.deleted_at = new Date().toISOString(); if (currentPatternId === id) currentPatternId = null; localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); renderPatternLibrary(); populatePatternSelect(); } function viewPatternFromLibrary(id) { const p = patterns.find(pat => pat.id === id && !pat.deleted_at); if (!p) return; const file = p.patternFile || p.draft?.patternFile; if (!file?.url) return; openFileViewer(file, p.name || p.title || 'Pattern', p.id); } function uploadToLibrary() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.pdf,.epub,.jpg,.jpeg,.png,.webp,image/*'; input.onchange = () => { if (input.files[0]) doUploadToLibrary(input.files[0]); }; input.click(); } async function doUploadToLibrary(file) { const detectedType = file.type === 'application/pdf' ? 'pdf' : (file.type === 'application/epub+zip' || file.name?.endsWith('.epub')) ? 'epub' : 'image'; const defaultName = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim(); let uploadedFile; if (auth.token) { try { const formData = new FormData(); formData.append('file', file); const resp = await fetch('/api/upload', { method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }, body: formData }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Upload failed'); uploadedFile = { url: data.url, type: data.type || detectedType, name: file.name }; } catch (err) { showAlert({ title: 'Upload failed', text: err.message }); return; } } else { uploadedFile = { url: URL.createObjectURL(file), type: detectedType, name: file.name, temporary: true }; } const id = `pat-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const now = new Date().toISOString(); const newPattern = { id, name: defaultName, title: defaultName, source: 'upload', patternFile: uploadedFile, draft: normalizePatternDraft({ meta: { title: defaultName } }), deleted_at: null, updated_at: now }; if (!Array.isArray(patterns)) patterns = []; patterns.unshift(newPattern); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); renderPatternLibrary(); populatePatternSelect(); openFileViewer(uploadedFile, defaultName); } function togglePartInstructions(pId, partId) { const project = findProjectById(pId); if (!project) return; const part = findPartById(project, 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} `; }).join(''); } function renderAbbrevChecklist() { const listEl = document.getElementById('patternAbbrevList'); if (!listEl) return; listEl.innerHTML = ''; const selected = new Set(patternDraft.abbrevSelection); const selectedPanel = document.createElement('div'); selectedPanel.className = 'abbrev-panel selected-panel'; selectedPanel.innerHTML = `
    Selected (${selected.size}) Tap to remove
    `; const selectedGrid = selectedPanel.querySelector('.selected-grid'); const library = getActiveAbbrevLibrary(); const selectedItems = library.filter(item => selected.has(item.code)); if (!selectedItems.length) { selectedGrid.innerHTML = '

    No stitches selected yet.

    '; } else { selectedItems.forEach(item => { const pill = createAbbrevPill(item, true); if (item.code === lastSelectedAbbrev) pill.classList.add('just-added'); selectedGrid.appendChild(pill); }); } listEl.appendChild(selectedPanel); const availablePanel = document.createElement('div'); availablePanel.className = 'abbrev-panel available-panel'; availablePanel.innerHTML = `
    All stitches
    `; const groupsWrap = document.createElement('div'); groupsWrap.className = 'abbrev-groups'; getAbbrevGroups().forEach(bucket => { const availableItems = bucket.items.filter(item => !selected.has(item.code)); if (!availableItems.length) return; const wrap = document.createElement('div'); wrap.className = 'abbrev-group'; const summary = document.createElement('div'); summary.className = 'abbrev-group-title'; summary.textContent = `${bucket.label} (${availableItems.length})`; wrap.appendChild(summary); const grid = document.createElement('div'); grid.className = 'abbrev-grid'; availableItems.forEach(item => { const pill = createAbbrevPill(item, false); grid.appendChild(pill); }); wrap.appendChild(grid); groupsWrap.appendChild(wrap); }); availablePanel.appendChild(groupsWrap); listEl.appendChild(availablePanel); lastSelectedAbbrev = ''; // Add Custom Abbrev UI const customWrap = document.createElement('div'); customWrap.className = 'abbrev-group custom-add-group'; customWrap.style.marginTop = '20px'; customWrap.innerHTML = `
    Add Custom Abbreviation
    `; 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.lookup = `${item.code} ${item.desc}`.toLowerCase(); 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); lastSelectedAbbrev = code; pill.classList.add('is-selected'); } updateAbbrevFromSelection(); renderAbbrevChecklist(); }); 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(); } // --- Pattern Composer UX Improvements --- function updatePatternHeaderTitle() { const el = document.getElementById('patternHeaderTitle'); if (!el) return; const title = patternDraft.meta.title?.trim() || ''; el.textContent = title; el.classList.toggle('is-visible', !!title); } function toggleSpecsCard(id) { const card = document.getElementById(id); if (!card) return; card.classList.toggle('is-collapsed'); try { localStorage.setItem(`specCard_${id}`, card.classList.contains('is-collapsed') ? '1' : '0'); } catch(e) {} } function restoreSpecsCardStates() { ['specCard-basic', 'specCard-yarns', 'specCard-materials', 'specCard-abbrev'].forEach(id => { const card = document.getElementById(id); if (!card) return; const saved = localStorage.getItem(`specCard_${id}`); // Defaults: basic = open, rest = collapsed const defaultCollapsed = id !== 'specCard-basic'; const shouldCollapse = saved !== null ? saved === '1' : defaultCollapsed; card.classList.toggle('is-collapsed', shouldCollapse); }); } function updateTabDots() { const specs = { 'info': !!(patternDraft.meta.title?.trim() || patternDraft.meta.designer?.trim() || patternDraft.yarns.length || patternDraft.hooks.length || patternDraft.materials?.trim()), 'steps': patternDraft.steps.length > 0, 'view': patternDraft.steps.some(s => s.rows.length > 0), 'library': patterns.filter(p => !p.deleted_at).length > 0 }; Object.entries(specs).forEach(([tab, filled]) => { const dot = document.getElementById(`tabDot-${tab}`); if (dot) dot.classList.toggle('is-filled', filled); }); } const patternTemplates = { amigurumi: { icon: '🧸', name: 'Amigurumi', desc: 'Head, body, limbs', steps: ['Head', 'Body', 'Arms (×2)', 'Legs (×2)', 'Tail / Extras', 'Assembly'] }, flat: { icon: '🧣', name: 'Flat Piece', desc: 'Foundation + body', steps: ['Foundation Chain', 'Main Body', 'Edging / Border'] }, accessory: { icon: '🧢', name: 'Accessory', desc: 'Hat, cowl, etc.', steps: ['Crown', 'Body', 'Brim / Cuff'] }, blank: { icon: '📄', name: 'Blank', desc: 'Start from scratch', steps: ['Step 1'] } }; function applyPatternTemplate(key) { const tpl = patternTemplates[key]; if (!tpl) return; patternDraft.steps = tpl.steps.map(title => ({ title, rows: [], rowDraft: '', note: '', image: '', collapsed: false, rowDone: {} })); persistPatternDraft(); renderSteps(); } function openPatternComposer() { if (!patternOverlay) return; patternOverlay.classList.add('active'); syncPatternUI(); restoreSpecsCardStates(); renderPatternLibrary(); renderPatternView(); updateTabDots(); const savedTab = localStorage.getItem('patternActiveTab') || 'steps'; showPatternTab(savedTab); } function closePatternComposer() { if (!patternOverlay) return; patternOverlay.classList.remove('active'); } let lastSelectedAbbrev = ''; syncPatternUI(); bindPatternInputs(); renderAbbrevChecklist(); restoreSpecsCardStates(); let pendingSaveSelection = []; let pendingImportProjects = []; let pendingImportPatterns = []; let pendingImportMeta = null; let lastCountPulse = null; let lastFinishedId = null; let fireflyTimer = null; let fireflyActive = false; let titleClicks = []; let easterEggCooling = false; // --- Auth State (backend) --- function normalizeEmailValue(value) { return String(value || '').trim().toLowerCase(); } function setAuthFormLoading(form, isLoading) { if (!form) return; const inputs = form.querySelectorAll('input, button[type="submit"]'); inputs.forEach(input => { input.disabled = isLoading; }); const submit = form.querySelector('button[type="submit"]'); if (submit) { if (isLoading) { submit.dataset.originalText = submit.textContent; submit.textContent = 'Working...'; } else if (submit.dataset.originalText) { submit.textContent = submit.dataset.originalText; delete submit.dataset.originalText; } } } function resetAuthLocal(message) { auth = { token: '', email: '', isAdmin: false, status: 'unknown', mode: 'login' }; localStorage.removeItem('authToken'); updateAuthUI(); if (message) showAlert({ title: 'Signed out', text: message }); } 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 authModal = document.querySelector('.auth-modal'); 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 (authModal) authModal.classList.toggle('is-logged-out', !signedIn); if (patternFab) patternFab.style.display = signedIn ? 'inline-flex' : 'none'; if (patternPicker) patternPicker.style.display = signedIn && patterns.length ? 'block' : 'none'; if (syncBanner) syncBanner.style.display = signedIn ? 'none' : 'inline-flex'; if (badge) { const statusSuffix = auth.status && auth.status !== 'active' ? ` (${auth.status})` : ''; badge.textContent = signedIn ? `Signed in: ${auth.email || 'Account'}${statusSuffix}` : 'Signed out'; badge.classList.toggle('is-on', signedIn); } if (lastSync) { const lastSyncedAt = localStorage.getItem('lastSyncAt'); if (signedIn) { if (auth.status && auth.status !== 'active') { lastSync.textContent = `Status: ${auth.status}`; } else if (lastSyncedAt) { lastSync.textContent = `Last sync: ${new Date(lastSyncedAt).toLocaleString()}`; } else { lastSync.textContent = 'Not synced yet'; } } else { lastSync.textContent = 'Local-only mode'; } } // 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"], .auth-tab[data-mode="reset"]').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' || auth.mode === 'reset') { 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"], .auth-tab[data-mode="reset"]').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 form = event?.target || document.querySelector(`#${mode}Content form`); const emailInput = document.getElementById(`${mode}Email`); const passwordInput = document.getElementById(`${mode}Password`); const email = normalizeEmailValue(emailInput?.value); const password = passwordInput?.value || ''; if (emailInput) emailInput.value = email; 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; } if (password.length < 8) { showAlert({ title: 'Weak Password', text: 'Use at least 8 characters.' }); return false; } } try { setAuthFormLoading(form, true); 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) { if (resp.status === 403 && data.status) { const statusLabel = data.status === 'pending' ? 'pending approval' : data.status; showAlert({ title: 'Account not active', text: `This account is ${statusLabel}.` }); } else if (resp.status === 409 && mode === 'signup') { showAlert({ title: 'Account Exists', text: data.error || 'Try logging in instead.' }); const loginEmail = document.getElementById('loginEmail'); if (loginEmail) loginEmail.value = email; setAuthMode('login'); } else { showAlert({ title: 'Auth failed', text: data.error || 'Invalid credentials' }); } return false; } if (!data.token && data.status) { showAlert({ title: 'Signup received', text: 'Your account is pending approval. You will be able to sign in once approved.' }); setAuthMode('login'); return false; } if (!data.token) { showAlert({ title: 'Auth failed', text: 'Missing session token. Please try again.' }); return false; } auth = { token: data.token, email: data.email || email, isAdmin: !!data.is_admin, status: data.status || 'active', mode: 'login' }; localStorage.setItem('authToken', auth.token); updateAuthUI(); closeAuthModal(); await fetchProfile(); await reconcileAfterLogin(); } catch (err) { showAlert({ title: 'Auth failed', text: err.message }); } finally { setAuthFormLoading(form, false); } return false; } async function autoSync() { await fetchProfile(); await syncData(); } async function reconcileAfterLogin() { if (!auth.token) return; const indicator = document.getElementById('authLastSync'); if (indicator) indicator.textContent = 'Checking cloud data...'; try { const resp = await fetch('/api/sync', { headers: { Authorization: `Bearer ${auth.token}` } }); const data = await resp.json(); if (resp.status === 401 || resp.status === 403) { resetAuthLocal('Session expired. Please sign in again.'); return; } if (!resp.ok) { throw new Error(data.error || 'Sync check failed'); } const serverProjects = normalizeProjects(data.projects || []); const serverPatterns = mapSyncedPatterns(data.patterns || []); const localHas = (projects?.length || 0) + (patterns?.length || 0) > 0; const serverHas = (serverProjects.length + serverPatterns.length) > 0; const syncPreference = localStorage.getItem('syncPreference'); if (!localHas && serverHas) { projects = serverProjects; patterns = serverPatterns; localStorage.setItem('crochetCounters', JSON.stringify(projects)); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); backfillPatternInstructions(); render(); renderPatternLibrary(); populatePatternSelect(); const syncedAt = new Date().toISOString(); localStorage.setItem('lastSyncAt', syncedAt); updateAuthUI(); autoLoadRecentPattern(); return; } if (!serverHas && localHas) { if (syncPreference === 'use-server') { projects = []; patterns = []; localStorage.setItem('crochetCounters', JSON.stringify(projects)); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); render(); renderPatternLibrary(); populatePatternSelect(); updateAuthUI(); return; } if (syncPreference === 'use-local') { await syncData({ silent: true }); autoLoadRecentPattern(); return; } const decision = await showConfirmWithRemember({ title: 'Sync setup', text: 'Your cloud is empty. Use cloud (clears local data) or keep your local data and upload it?', confirmText: 'Use cloud', cancelText: 'Keep local' }); const useServer = decision.confirmed; if (decision.remember) { localStorage.setItem('syncPreference', useServer ? 'use-server' : 'use-local'); } if (useServer) { projects = []; patterns = []; localStorage.setItem('crochetCounters', JSON.stringify(projects)); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); render(); renderPatternLibrary(); populatePatternSelect(); } else { await syncData({ silent: true }); autoLoadRecentPattern(); } return; } if (localHas && serverHas) { if (syncPreference === 'use-server') { projects = serverProjects; patterns = serverPatterns; localStorage.setItem('crochetCounters', JSON.stringify(projects)); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); backfillPatternInstructions(); render(); renderPatternLibrary(); populatePatternSelect(); const syncedAt = new Date().toISOString(); localStorage.setItem('lastSyncAt', syncedAt); updateAuthUI(); autoLoadRecentPattern(); return; } if (syncPreference === 'use-local') { await syncData({ silent: true }); autoLoadRecentPattern(); return; } const decision = await showConfirmWithRemember({ title: 'Sync conflict', text: 'Local data and cloud data both exist. Use cloud data or keep local data and upload it?', confirmText: 'Use cloud', cancelText: 'Keep local' }); const useServer = decision.confirmed; if (decision.remember) { localStorage.setItem('syncPreference', useServer ? 'use-server' : 'use-local'); } if (useServer) { projects = serverProjects; patterns = serverPatterns; localStorage.setItem('crochetCounters', JSON.stringify(projects)); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); backfillPatternInstructions(); render(); renderPatternLibrary(); populatePatternSelect(); const syncedAt = new Date().toISOString(); localStorage.setItem('lastSyncAt', syncedAt); updateAuthUI(); autoLoadRecentPattern(); } else { await syncData({ silent: true }); autoLoadRecentPattern(); } return; } updateAuthUI(); } catch (err) { console.error(err); if (indicator) indicator.textContent = 'Sync check failed'; showAlert({ title: 'Sync check failed', text: err.message }); } } function buildSyncProjectPayload(items = []) { return (items || []).map(project => { const normalized = normalizeProjectItem(project); const data = { ...normalized }; return { id: normalized.id, data, deleted_at: normalized.deleted_at || null }; }); } function buildSyncPatternPayload(items = []) { return (items || []).map(item => { const normalized = normalizePatternLibraryItem(item); const data = { ...(normalized.data || { draft: normalized.draft }), source: normalized.source || 'composed', patternFile: normalized.patternFile || null }; return { id: normalized.id, title: normalized.title || normalized.name || '', slug: normalized.slug || null, data, deleted_at: normalized.deleted_at || null }; }); } function mapSyncedPatterns(items = []) { return (items || []).map(item => { const draft = normalizePatternDraft(item.data?.draft || item.data || {}); if (!draft.savedAt && item.updated_at) draft.savedAt = item.updated_at; const title = item.title || draft.meta?.title || 'Pattern'; return { id: item.id, name: title, title, source: item.data?.source || 'composed', patternFile: item.data?.patternFile || null, draft, data: item.data || { draft }, slug: item.slug || null, deleted_at: item.deleted_at || null, updated_at: item.updated_at || null }; }); } async function syncData(options = {}) { const { silent = false } = options; if (!auth.token) return; const indicator = document.getElementById('authLastSync'); if (indicator) indicator.textContent = 'Syncing...'; try { // 1. Push Local Data const payloadProjects = buildSyncProjectPayload(projects); const payloadPatterns = buildSyncPatternPayload(patterns); const pushResp = await fetch('/api/sync', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` }, body: JSON.stringify({ projects: payloadProjects, patterns: payloadPatterns }) }); if (pushResp.status === 401 || pushResp.status === 403) { resetAuthLocal('Session expired. Please sign in again.'); return; } if (!pushResp.ok) throw new Error('Push failed'); // 2. Pull Server Data const pullResp = await fetch('/api/sync', { headers: { Authorization: `Bearer ${auth.token}` } }); const data = await pullResp.json(); if (pullResp.status === 401 || pullResp.status === 403) { resetAuthLocal('Session expired. Please sign in again.'); return; } if (!pullResp.ok) throw new Error(data.error || 'Pull failed'); // 3. Merge/Update Local if (Array.isArray(data.projects)) { projects = normalizeProjects(data.projects); localStorage.setItem('crochetCounters', JSON.stringify(projects)); } if (Array.isArray(data.patterns)) { patterns = mapSyncedPatterns(data.patterns); patterns = dedupePatternsByName(patterns); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); } backfillPatternInstructions(); localStorage.setItem('apiBase', window.location.origin); render(); renderPatternLibrary(); const syncedAt = new Date().toISOString(); localStorage.setItem('lastSyncAt', syncedAt); if (indicator) indicator.textContent = `Synced: ${new Date(syncedAt).toLocaleTimeString()}`; updateAuthUI(); if (!silent) { showAlert({ title: 'Sync Complete', text: 'Your data is up to date.' }); } } catch (err) { console.error(err); if (indicator) indicator.textContent = 'Sync failed'; showAlert({ title: 'Sync Error', text: err.message }); } } async function saveProfile(event) { if (event) event.preventDefault(); const displayName = (document.getElementById('authDisplayName') || {}).value || ''; const note = (document.getElementById('authNote') || {}).value || ''; try { const resp = await fetch('/api/me', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${auth.token}` }, body: JSON.stringify({ displayName, note }) }); if (resp.status === 401 || resp.status === 403) { resetAuthLocal('Session expired. Please sign in again.'); return false; } 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}` } }); if (resp.status === 401 || resp.status === 403) { resetAuthLocal('Session expired. Please sign in again.'); return; } 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) {} resetAuthLocal(); closeAuthModal(); } async function requestPasswordReset(event) { if (event) event.preventDefault(); const emailInput = document.getElementById('resetEmail'); const email = normalizeEmailValue(emailInput?.value); if (emailInput) emailInput.value = email; if (!email) { showAlert({ title: 'Missing Info', text: 'Please enter your email.' }); return false; } const form = event?.target || document.querySelector('#resetContent form'); try { setAuthFormLoading(form, true); const resp = await fetch('/api/password-reset/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Reset request failed'); showAlert({ title: 'Check your email', text: 'If that account exists, a password reset link has been sent. The link expires in 1 hour.' }); } catch (err) { showAlert({ title: 'Reset request failed', text: err.message }); } finally { setAuthFormLoading(form, false); } return false; } async function confirmPasswordReset(event) { if (event) event.preventDefault(); const token = (document.getElementById('resetToken') || {}).value || ''; const password = (document.getElementById('resetPassword') || {}).value || ''; const confirm = (document.getElementById('resetConfirmPassword') || {}).value || ''; if (!token || !password) { showAlert({ title: 'Missing Info', text: 'Please enter your reset token and new password.' }); return false; } if (password !== confirm) { showAlert({ title: 'Passwords Do Not Match', text: 'Please re-enter your passwords.' }); return false; } if (password.length < 8) { showAlert({ title: 'Weak Password', text: 'Use at least 8 characters.' }); return false; } const form = event?.target || document.querySelector('#resetConfirmForm'); try { setAuthFormLoading(form, true); const resp = await fetch('/api/password-reset/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Reset failed'); showAlert({ title: 'Password updated', text: 'You can now sign in with your new password.' }); setAuthMode('login'); } catch (err) { showAlert({ title: 'Reset failed', text: err.message }); } finally { setAuthFormLoading(form, false); } return 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(); // Initialize auth UI updateAuthUI(); // expose auth helpers window.openAuthModal = openAuthModal; window.closeAuthModal = closeAuthModal; window.setAuthMode = setAuthMode; window.submitAuth = submitAuth; window.requestPasswordReset = requestPasswordReset; window.confirmPasswordReset = confirmPasswordReset; window.viewerCountChange = viewerCountChange; window.viewerZoomChange = viewerZoomChange; window.viewerZoomReset = viewerZoomReset; window.autoSync = autoSync; window.saveProfile = saveProfile; window.logoutAuth = logoutAuth; window.savePatternDraft = savePatternDraft; window.savePatternAndStartProject = savePatternAndStartProject; window.loadPatternFromLibrary = loadPatternFromLibrary; window.startProjectFromLibrary = startProjectFromLibrary; window.sharePatternFromLibrary = sharePatternFromLibrary; window.renderPatternLibrary = renderPatternLibrary; window.deletePatternFromLibrary = deletePatternFromLibrary; window.sharePattern = sharePattern; window.clearPatternOutput = clearPatternOutput; window.openImagePreview = openImagePreview; window.addFinishedPhoto = addFinishedPhoto; window.updateFinishedPhoto = updateFinishedPhoto; window.removeFinishedPhoto = removeFinishedPhoto; window.uploadFinishedPhoto = uploadFinishedPhoto; window.togglePartInstructions = togglePartInstructions; window.fetchPendingUsers = fetchPendingUsers; window.downloadBackup = downloadBackup; window.uploadRestore = uploadRestore; window.setAuthMode = setAuthMode; window.approveUser = approveUser; window.suspendUser = suspendUser; window.openSettingsModal = openSettingsModal; window.closeSettingsModal = closeSettingsModal; window.setDarkMode = setDarkMode; window.setAnimations = setAnimations; window.reconcileAfterLogin = reconcileAfterLogin; window.showConfirmWithRemember = showConfirmWithRemember; window.makeAdmin = makeAdmin; window.removePatternTag = removePatternTag; window.setShelfFilter = setShelfFilter; window.openLastReadPattern = openLastReadPattern; // --- 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); }; document.body.appendChild(overlay); }); } function showConfirmWithRemember({ title = 'Are you sure?', text = '', confirmText = 'Yes', cancelText = 'Cancel', danger = false, rememberText = 'Remember this choice' } = {}) { 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'); const rememberInput = overlay.querySelector('#swalRememberChoice'); cancelBtn.onclick = () => { removeSwal(); resolve({ confirmed: false, remember: rememberInput?.checked || false }); }; confirmBtn.onclick = () => { removeSwal(); resolve({ confirmed: true, remember: rememberInput?.checked || false }); }; 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 = `
    ${title}
    ${text}
    `; 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() { const themeBtn = document.getElementById('themeBtn'); if (isDarkMode) { document.body.classList.add('dark-mode'); if (themeBtn) themeBtn.innerHTML = ''; } else { document.body.classList.remove('dark-mode'); if (themeBtn) themeBtn.innerHTML = ''; } handleAmbientDrift(); updateMotionBtn(); updateSettingsUI(); } 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(); } updateSettingsUI(); } function updateSettingsUI() { if (settingDarkMode) settingDarkMode.checked = !!isDarkMode; if (settingAnimations) settingAnimations.checked = !!animationsEnabled; } function openSettingsModal() { if (!settingsOverlay) return; updateSettingsUI(); settingsOverlay.classList.add('active'); } function closeSettingsModal() { if (!settingsOverlay) return; settingsOverlay.classList.remove('active'); } function setDarkMode(enabled) { if (isDarkMode === !!enabled) return; isDarkMode = !!enabled; localStorage.setItem('crochetDarkMode', isDarkMode); applyTheme(); if (animationsEnabled) { document.body.classList.add('theme-animating'); setTimeout(() => document.body.classList.remove('theme-animating'), 750); } } function setAnimations(enabled) { if (animationsEnabled === !!enabled) return; animationsEnabled = !!enabled; localStorage.setItem('crochetAnimations', animationsEnabled); updateMotionBtn(); if (!animationsEnabled) { stopAmbientDrift(); document.body.classList.remove('theme-animating'); } else { handleAmbientDrift(); } updateSettingsUI(); } function openColorPicker(pId, partId) { if (!colorOverlay || !colorGrid) return; const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; 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 visiblePatterns = patterns.filter(p => !p.deleted_at); const hasPatterns = visiblePatterns.length > 0; patternPicker.style.display = hasPatterns ? 'block' : 'none'; patternSelect.innerHTML = '' + visiblePatterns.map(p => { const draft = p.draft || p.data?.draft || {}; const title = p.name || p.title || draft.meta?.title || 'Pattern'; return ``; }).join(''); } function exportData(selectedProjects = projects) { const safeProjects = Array.isArray(selectedProjects) ? selectedProjects : []; const payload = { projects: safeProjects, isDarkMode, animationsEnabled, patterns }; let json = ''; try { json = JSON.stringify(payload, null, 2); if (!json || !json.trim()) throw new Error('No data to export'); } catch (err) { console.error('Export failed', err); showAlert({ title: 'Export failed', text: err.message || 'Could not export data.' }); return; } const names = safeProjects.map(p => p.name || 'Project').join('_').replace(/\s+/g, '-').slice(0, 50) || 'projects'; const filename = `toadstool_${names}.json`; const url = `data:application/json;charset=utf-8,${encodeURIComponent(json)}`; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // data: URI avoids object URL timing issues that can create 0-byte downloads } function triggerImport() { if (!importInput) return; importInput.value = ''; importInput.click(); } function normalizeImportedProject(project, idx, existingProjectIds, existingPartIds) { let projectId = project.id; if (projectId === undefined || existingProjectIds.has(String(projectId))) { projectId = Date.now() + idx + Math.floor(Math.random() * 1000); } existingProjectIds.add(String(projectId)); const fallbackColor = project.color || colors[(projects.length + idx) % colors.length]; const normalizedParts = Array.isArray(project.parts) ? project.parts : []; const normalized = { id: projectId, name: project.name || `Project ${projects.length + idx + 1}`, color: fallbackColor, collapsed: !!project.collapsed, note: project.note || '', patternId: project.patternId || null, parts: normalizedParts.map((part, partIdx) => { let partId = part.id; if (partId === undefined || existingPartIds.has(String(partId))) { partId = Date.now() + idx * 1000 + partIdx + 1; } existingPartIds.add(String(partId)); const parsedMax = part.max === undefined || part.max === null ? null : Number(part.max); return { id: partId, name: part.name || `Part ${partIdx + 1}`, count: Number.isFinite(part.count) ? part.count : 0, locked: !!part.locked, finished: !!part.finished, minimized: !!part.minimized, max: Number.isFinite(parsedMax) ? parsedMax : null, color: part.color || fallbackColor, note: part.note || '', instructionsCollapsed: part.instructionsCollapsed !== undefined ? !!part.instructionsCollapsed : true, instructions: part.instructions ? { title: part.instructions.title || '', rows: Array.isArray(part.instructions.rows) ? part.instructions.rows : [], note: part.instructions.note || '', image: part.instructions.image || '' } : null }; }) }; return normalized; } function renderImportSelection() { if (!importList) return; importList.innerHTML = ''; if (!pendingImportProjects.length) { importList.innerHTML = '

    No projects found in file.

    '; return; } pendingImportProjects.forEach((p, idx) => { const partsCount = Array.isArray(p.parts) ? p.parts.length : 0; const item = document.createElement('label'); item.className = 'save-item'; item.innerHTML = ` ${p.name || `Project ${idx + 1}`} (${partsCount} part${partsCount === 1 ? '' : 's'}) `; importList.appendChild(item); }); } async function handleImport(event) { const file = event.target.files[0]; if (!file) return; // Check file extension const fileName = file.name.toLowerCase(); if (fileName.endsWith('.sql')) { showAlert({ title: 'Import failed', text: 'SQL files can only be used for Admin database restore, not project/pattern import.' }); event.target.value = ''; return; } try { const text = await file.text(); if (!text || !text.trim()) throw new Error('File is empty'); const data = JSON.parse(text); if (!data.projects || !Array.isArray(data.projects)) throw new Error('Invalid file'); pendingImportProjects = data.projects.map((p, idx) => ({ ...p, __importKey: `imp-${Date.now()}-${idx}` })); pendingImportPatterns = Array.isArray(data.patterns) ? data.patterns : []; pendingImportMeta = { isDarkMode: typeof data.isDarkMode === 'boolean' ? data.isDarkMode : null, animationsEnabled: typeof data.animationsEnabled === 'boolean' ? data.animationsEnabled : null }; if (importSelection && importList) { renderImportSelection(); importSelection.classList.remove('hidden'); if (saveOverlay) saveOverlay.classList.add('active'); } else { // Fallback if UI elements are missing: import everything immediately applyImportSelection(true); } } catch (err) { showAlert({ title: 'Import failed', text: err.message }); } event.target.value = ''; } if (importInput) { importInput.addEventListener('change', handleImport); } function cancelImportSelection() { pendingImportProjects = []; pendingImportPatterns = []; pendingImportMeta = null; if (importSelection) importSelection.classList.add('hidden'); if (importList) importList.innerHTML = ''; if (saveOverlay) saveOverlay.classList.remove('active'); } function applyImportSelection(applyAll = false) { const selectedKeys = applyAll || !importList ? pendingImportProjects.map(p => p.__importKey) : Array.from(importList.querySelectorAll('input[type="checkbox"]')) .filter(i => i.checked) .map(i => i.dataset.importId); const existingProjectIds = new Set(projects.map(p => String(p.id))); const existingPartIds = new Set(); projects.forEach(p => p.parts.forEach(pt => existingPartIds.add(String(pt.id)))); const chosenProjects = pendingImportProjects.filter(p => selectedKeys.includes(p.__importKey)); const normalizedProjects = chosenProjects.map((p, idx) => normalizeImportedProject(p, idx, existingProjectIds, existingPartIds)); if (normalizedProjects.length) { projects = [...projects, ...normalizedProjects]; localStorage.setItem('crochetCounters', JSON.stringify(projects)); } let addedPatternCount = 0; if (pendingImportPatterns.length) { const existingPatternIds = new Set(patterns.map(p => String(p.id))); const dedupedPatterns = pendingImportPatterns.map((pat, idx) => { if (pat.id === undefined || existingPatternIds.has(String(pat.id))) { return { ...pat, id: `${Date.now()}-${idx}` }; } return pat; }).filter(pat => !existingPatternIds.has(String(pat.id))); addedPatternCount = dedupedPatterns.length; if (addedPatternCount) { patterns = normalizePatternLibrary([...patterns, ...dedupedPatterns]); patterns = dedupePatternsByName(patterns); localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); renderPatternLibrary(); populatePatternSelect(); } } if (pendingImportMeta) { if (typeof pendingImportMeta.isDarkMode === 'boolean') { isDarkMode = pendingImportMeta.isDarkMode; localStorage.setItem('crochetDarkMode', isDarkMode); } if (typeof pendingImportMeta.animationsEnabled === 'boolean') { animationsEnabled = pendingImportMeta.animationsEnabled; localStorage.setItem('crochetAnimations', animationsEnabled); } applyTheme(); updateMotionBtn(); } render(); cancelImportSelection(); const summaryParts = []; if (normalizedProjects.length) summaryParts.push(`${normalizedProjects.length} project${normalizedProjects.length === 1 ? '' : 's'}`); if (addedPatternCount) summaryParts.push(`${addedPatternCount} pattern${addedPatternCount === 1 ? '' : 's'}`); if (summaryParts.length) { showAlert({ title: 'Import complete', text: `${summaryParts.join(', ')} added.` }); } else { showAlert({ title: 'Import', text: 'No new projects selected.' }); } } function openSaveModal() { if (!saveOverlay || !saveList) return; saveList.innerHTML = ''; const visibleProjects = projects.filter(p => !p.deleted_at); pendingSaveSelection = visibleProjects.map(p => p.id); visibleProjects.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 => i.dataset.id); if (selectedIds.length === 0) { closeSaveModal(); return; } const selectedProjects = projects.filter(p => !p.deleted_at && selectedIds.includes(String(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 viewerWakeLock = 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(); } // --- Drag-to-reorder (projects & parts) --- let cardDrag = null; function getDragAfterElement(container, y, selector) { const els = [...container.querySelectorAll(selector)].filter(el => !el.classList.contains('dragging')); return els.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) return { offset, element: child }; return closest; }, { offset: -Infinity, element: null }).element; } function beginCardDrag(e, card, kind, projectId) { if (!card) return; if (e.pointerType === 'mouse' && e.button !== 0) return; e.preventDefault(); const container = kind === 'project' ? card.closest('.projects-grid') : card.closest('.part-list'); if (!container) return; const rect = card.getBoundingClientRect(); const ghost = card.cloneNode(true); ghost.classList.add('drag-ghost'); ghost.removeAttribute('id'); ghost.querySelectorAll('[id]').forEach(el => el.removeAttribute('id')); ghost.style.width = `${rect.width}px`; ghost.style.left = `${rect.left}px`; ghost.style.top = `${rect.top}px`; document.body.appendChild(ghost); card.classList.add('dragging'); cardDrag = { pointerId: e.pointerId, card, container, kind, projectId, ghost, grabOffsetY: e.clientY - rect.top }; window.addEventListener('pointermove', onCardDragMove); window.addEventListener('pointerup', endCardDrag); window.addEventListener('pointercancel', endCardDrag); } function onCardDragMove(e) { if (!cardDrag || e.pointerId !== cardDrag.pointerId) return; e.preventDefault(); cardDrag.ghost.style.top = `${e.clientY - cardDrag.grabOffsetY}px`; const selector = cardDrag.kind === 'project' ? '.project-container' : '.part-card'; const after = getDragAfterElement(cardDrag.container, e.clientY, selector); if (after == null) { if (cardDrag.container.lastElementChild !== cardDrag.card) cardDrag.container.appendChild(cardDrag.card); } else if (after !== cardDrag.card) { cardDrag.container.insertBefore(cardDrag.card, after); } } function endCardDrag(e) { if (!cardDrag || (e && e.pointerId !== cardDrag.pointerId)) return; const { card, container, kind, projectId, ghost } = cardDrag; window.removeEventListener('pointermove', onCardDragMove); window.removeEventListener('pointerup', endCardDrag); window.removeEventListener('pointercancel', endCardDrag); ghost.remove(); card.classList.remove('dragging'); cardDrag = null; if (kind === 'project') { const orderedIds = [...container.querySelectorAll('.project-container')].map(el => el.dataset.projectId); const byId = new Map(projects.map(p => [String(p.id), p])); const reordered = orderedIds.map(id => byId.get(id)).filter(Boolean); const remaining = projects.filter(p => !orderedIds.includes(String(p.id))); projects = [...reordered, ...remaining]; } else { const project = findProjectById(projectId); if (project) { const orderedIds = [...container.querySelectorAll('.part-card')].map(el => el.dataset.partId); const byId = new Map((project.parts || []).map(pt => [String(pt.id), pt])); project.parts = orderedIds.map(id => byId.get(id)).filter(Boolean); } } save(); } 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 (document.visibilityState === 'visible') { if (wakeLock !== null) wakeLock = await navigator.wakeLock.request('screen'); if (viewerWakeLock !== null) viewerWakeLock = await navigator.wakeLock.request('screen'); } }); // --- Interaction Logic --- async function deleteProject(pId) { if (pId === undefined || pId === null) { showAlert({ title: 'Not found', text: 'Project could not be found.' }); return; } const project = findProjectById(pId); if (!project) { showAlert({ title: 'Not found', text: 'Project could not be found.' }); return; } const ok = await showConfirm({ title: 'Delete project?', text: 'This will remove the entire project.', confirmText: 'Delete', danger: true }); if (ok) { project.deleted_at = new Date().toISOString(); save(); } } function toggleProjectCollapse(pId) { const project = findProjectById(pId); if (!project) return; project.collapsed = !project.collapsed; save(); } function renameProject(pId) { modalState = { type: 'renameProject', pId, partId: null }; const project = findProjectById(pId); if (!project) { showAlert({ title: 'Not found', text: 'Project could not be found.' }); return; } 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 = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; 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 => String(pt.id) !== String(partId)); save(); } } function togglePartMinimize(pId, partId) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; part.minimized = !part.minimized; save(); } function togglePartLock(pId, partId) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; part.locked = !part.locked; save(); } function setPartColor(pId, partId, color) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; part.color = color; save(); } function togglePartFinish(pId, partId) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; part.finished = !part.finished; if (part.finished) { part.locked = false; part.minimized = true; part.instructionsCollapsed = true; lastFinishedId = part.id; } else { part.minimized = false; lastFinishedId = null; } save(); } function updateCount(pId, partId, change) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; 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' }; setActivePart(String(pId), String(partId)); updateViewerCounterBar(); save(); } async function resetCount(pId, partId) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; 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 = findProjectById(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 project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; modalTitle.innerText = "Set Max Stitches"; modalInput.value = part.max ?? ''; modalInput.type = "number"; modalInput.placeholder = "Leave blank to clear"; } else if (type === 'renamePart') { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; 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 = findProjectById(pId); modalInput.value = project ? `${project.name} pattern` : ''; modalInput.type = "text"; modalInput.placeholder = "Pattern name"; } else if (type === 'manualCount') { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; if(part.locked || part.finished) return; modalTitle.innerText = "Set Row Count"; modalInput.value = part.count; modalInput.type = "number"; } else if (type === 'setMax') { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; 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 || [], note: st.note || '', image: st.image || '' } }); }); } 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 = findProjectById(modalState.pId); if (!project) return; 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 project = findProjectById(modalState.pId); const part = findPartById(project, modalState.partId); if (!project || !part) return; part.name = val; } else if (modalState.type === 'renameProject') { const project = findProjectById(modalState.pId); if (!project) return; project.name = val; } else if (modalState.type === 'manualCount') { const num = parseInt(val); if (!isNaN(num) && num >= 0) { const project = findProjectById(modalState.pId); const part = findPartById(project, modalState.partId); if (!project || !part) return; part.count = num; if (part.max !== null && part.count > part.max) part.count = part.max; } } else if (modalState.type === 'setMax') { const project = findProjectById(modalState.pId); const part = findPartById(project, modalState.partId); if (!project || !part) return; 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 = findProjectById(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 = findProjectById(pId); if (!project) return; project.note = e.target.value; localStorage.setItem('crochetCounters', JSON.stringify(projects)); } function updatePartNote(e, pId, partId) { const project = findProjectById(pId); const part = findPartById(project, partId); if (!project || !part) return; part.note = e.target.value; localStorage.setItem('crochetCounters', JSON.stringify(projects)); } // --- Render Logic --- function render() { app.innerHTML = ''; const visibleProjects = projects.filter(p => !p.deleted_at); if (visibleProjects.length === 0) { app.innerHTML = `

    Toadstools & twine await…

    Have a pattern? Drop it here or open it to follow along while you count.

    `; return; } const grid = document.createElement('div'); grid.className = 'projects-grid'; visibleProjects.forEach(project => { const safeParts = Array.isArray(project.parts) ? project.parts : []; const sortedParts = [...safeParts].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 isActive = activePartRef && String(activePartRef.projectId) === String(project.id) && String(activePartRef.partId) === String(part.id) ? 'is-active-part' : ''; const partCardFullClass = `${isLocked} ${isFinished} ${isMinimized} ${finishPulseClass} ${isActive}`; 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.name} ${part.count}
    ${actionsHtml}
    ${part.count}
    ${countSubtext}
    ${part.instructions ? `
    Pattern
    ${part.instructions.title || ''}
      ${(part.instructions.rows || []).map((r,i)=>`
    • Row ${i+1}: ${r}
    • `).join('') || '
    • No rows
    • '}
    ${part.instructions.note ? `
    ${part.instructions.note}
    ` : ''} ${part.instructions.image ? `Pattern step image` : ''}
    ` : ''}
    `; }); // Look up if linked pattern has a file attached const linkedPat = project.patternId ? (patterns || []).find(p => p.id === project.patternId && !p.deleted_at) : null; const linkedFile = linkedPat ? (linkedPat.draft?.patternFile || linkedPat.data?.draft?.patternFile || null) : null; const projectContainer = document.createElement('div'); projectContainer.className = `project-container ${projectCollapsedClass}`; projectContainer.style = `--project-color: ${project.color}`; projectContainer.dataset.projectId = String(project.id); const projectNoteId = `project-note-${project.id}`; projectContainer.innerHTML = `
    ${project.name}
    ${project.patternId ? ` ${linkedFile?.url ? `` : ``} ` : ` `}
    ${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); } async function sharePattern() { if (!auth.token) { showAlert({ title: 'Sign in required', text: 'Please sign in to create a share link.' }); return; } if (!currentPatternId) { showAlert({ title: 'Save required', text: 'Save this pattern to your shelf before sharing.' }); return; } try { await syncData({ silent: true }); const resp = await fetch(`/api/patterns/${currentPatternId}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` }, body: JSON.stringify({ isPublic: true }) }); const data = await resp.json(); if (resp.status === 401 || resp.status === 403) { resetAuthLocal('Session expired. Please sign in again.'); return; } if (!resp.ok) { throw new Error(data.error || 'Share failed'); } const base = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}`; const url = `${base}pattern-viewer.html?token=${encodeURIComponent(data.token)}`; 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 = ''; } } // --- Pattern File (composer attachment & reference viewer) --- function updateComposerFileButtons() { const viewBtn = document.getElementById('composerViewFileBtn'); if (viewBtn) viewBtn.style.display = patternDraft.patternFile?.url ? '' : 'none'; } function findLastReadPattern() { return (patterns || []) .filter(p => !p.deleted_at && p.data?.lastPage > 1) .sort((a, b) => (b.data.lastPage || 0) - (a.data.lastPage || 0)) .find(p => p.patternFile?.url || p.data?.patternFile?.url); } function openLastReadPattern() { const pat = findLastReadPattern(); if (!pat) return openReferenceViewer(); const file = pat.patternFile || pat.data?.patternFile; if (file?.url) openFileViewer(file, pat.title || pat.name || file.name, pat.id); } function updateRefFab() { const hasFile = !!(referenceFile?.url || patternDraft.patternFile?.url); const fab = document.getElementById('refFab'); if (fab) fab.style.display = hasFile ? '' : 'none'; // Find any pattern with saved reading progress const pill = document.getElementById('readingPill'); const pillLabel = document.getElementById('readingPillLabel'); if (!pill) return; const inProgress = findLastReadPattern(); if (inProgress) { const name = inProgress.title || inProgress.name || 'pattern'; const page = inProgress.data.lastPage; pillLabel.textContent = `Continue: ${name} · p.${page}`; pill.style.display = ''; } else { pill.style.display = 'none'; } } // Attach a source file to the pattern currently in the composer function composerAttachFile() { if (!auth.token) { showAlert({ title: 'Sign in required', text: 'Sign in to attach pattern files.' }); return; } const input = document.createElement('input'); input.type = 'file'; input.accept = '.pdf,.epub,.jpg,.jpeg,.png,.webp,image/*'; input.onchange = async () => { const file = input.files[0]; if (!file) return; try { const formData = new FormData(); formData.append('file', file); const resp = await fetch('/api/upload', { method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }, body: formData }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Upload failed'); patternDraft.patternFile = { url: data.url, type: data.type || 'image', name: file.name }; persistPatternDraft(); updateComposerFileButtons(); updateRefFab(); } catch (err) { showAlert({ title: 'Upload failed', text: err.message }); } }; input.click(); } // Open a reference file for a project's linked pattern function viewLinkedPatternFile(patternId) { const pat = (patterns || []).find(p => p.id === patternId && !p.deleted_at); if (!pat) return; const file = pat.draft?.patternFile || pat.data?.draft?.patternFile; if (file?.url) openFileViewer(file, pat.draft?.meta?.title || pat.title || file.name, patternId); } // Open reference viewer: prefers the active project's linked pattern file function openReferenceViewer() { if (activePartRef) { const project = findProjectById(activePartRef.projectId); if (project?.patternId) { const pat = (patterns || []).find(p => p.id === project.patternId && !p.deleted_at); const file = pat?.patternFile || pat?.draft?.patternFile || pat?.data?.patternFile || null; if (file?.url) { openFileViewer(file, pat.title || pat.name || file.name, project.patternId); return; } } } const file = referenceFile || patternDraft.patternFile; if (!file?.url) { uploadAndViewReference(); return; } openFileViewer(file, file.name || patternDraft.meta.title || 'Pattern', currentPatternId || null); } // Upload a file (or use blob URL for guests) and immediately view it async function doUploadAndView(file) { const detectedType = file.type === 'application/pdf' ? 'pdf' : (file.type === 'application/epub+zip' || file.name?.endsWith('.epub')) ? 'epub' : 'image'; if (!auth.token) { // Guest: temporary blob URL — gone on page reload but works for the session const refFile = { url: URL.createObjectURL(file), type: detectedType, name: file.name, temporary: true }; referenceFile = refFile; updateRefFab(); openFileViewer(refFile, file.name); return; } try { const formData = new FormData(); formData.append('file', file); const resp = await fetch('/api/upload', { method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }, body: formData }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Upload failed'); const refFile = { url: data.url, type: data.type || 'image', name: file.name }; referenceFile = refFile; localStorage.setItem('crochetReferenceFile', JSON.stringify(refFile)); updateRefFab(); openFileViewer(refFile, file.name); } catch (err) { showAlert({ title: 'Upload failed', text: err.message }); } } function uploadAndViewReference() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.pdf,.epub,.jpg,.jpeg,.png,.webp,image/*'; input.onchange = () => { if (input.files[0]) doUploadAndView(input.files[0]); }; input.click(); } // --- Active Part & Viewer Counter Bar --- function setActivePart(projectId, partId) { activePartRef = { projectId: String(projectId), partId: String(partId) }; localStorage.setItem('activePartRef', JSON.stringify(activePartRef)); } function updateViewerCounterBar() { const bar = document.getElementById('viewerCounterBar'); if (!bar) return; const project = activePartRef ? findProjectById(activePartRef.projectId) : null; const part = project ? findPartById(project, activePartRef.partId) : null; if (!part || part.finished) { bar.style.display = 'none'; return; } bar.style.display = 'flex'; const projEl = document.getElementById('viewerCounterProject'); const partEl = document.getElementById('viewerCounterPart'); const countEl = document.getElementById('viewerCountDisplay'); if (projEl) projEl.textContent = project.name; if (partEl) partEl.textContent = part.name; if (countEl) { const next = part.max != null ? `${part.count} / ${part.max}` : String(part.count); if (countEl.textContent !== next) { countEl.textContent = next; countEl.classList.remove('viewer-count-bump'); void countEl.offsetWidth; countEl.classList.add('viewer-count-bump'); } } const minusBtn = document.getElementById('viewerCountMinus'); const plusBtn = document.getElementById('viewerCountPlus'); const dimmed = part.locked; if (minusBtn) minusBtn.disabled = dimmed || part.count <= 0; if (plusBtn) plusBtn.disabled = dimmed || (part.max != null && part.count >= part.max); } function viewerCountChange(delta) { if (!activePartRef) return; updateCount(activePartRef.projectId, activePartRef.partId, delta); } // --- Pattern File Viewer --- let viewerType = null; let viewerCurrentPage = 1; let viewerTotalPages = 1; let viewerPdfDoc = null; let viewerFileUrl = null; let viewerPatternId = null; let viewerZoom = 1.0; let viewerPinchStartDist = null; let viewerPinchStartZoom = 1.0; let viewerPageFlip = null; let viewerPageDivs = []; let viewerRenderedPages = new Set(); function loadViewerScript(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } const s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error(`Failed to load ${src}`)); document.head.appendChild(s); }); } function openPatternFileViewer() { openFileViewer(patternDraft.patternFile, patternDraft.meta.title, currentPatternId || null); } function viewerPageKey() { return viewerPatternId ? `crochetViewerPage:id:${viewerPatternId}` : `crochetViewerPage:${viewerFileUrl}`; } function viewerSavedPage(totalPages) { // Server-stored lastPage takes priority (cross-device), then localStorage if (viewerPatternId) { const pat = (patterns || []).find(p => p.id === viewerPatternId); const serverPage = pat?.data?.lastPage; if (serverPage) return Math.min(serverPage, totalPages); } return Math.min(parseInt(localStorage.getItem(viewerPageKey())) || 1, totalPages); } function saveViewerPage(pageNum) { localStorage.setItem(viewerPageKey(), pageNum); // Also stash in the pattern object so it gets included in the next sync push if (viewerPatternId) { const pat = (patterns || []).find(p => p.id === viewerPatternId); if (pat) { if (!pat.data) pat.data = {}; pat.data.lastPage = pageNum; localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); } } } function viewerWantSpread() { // Two-page spread on wide landscape screens; single page on mobile / portrait return window.innerWidth >= 900 && window.innerWidth > window.innerHeight; } function viewerPageFlipOptions(stage, forcePortrait = false) { const spread = !forcePortrait && viewerWantSpread(); return { width: spread ? Math.round(stage.clientWidth / 2) : stage.clientWidth || 400, height: stage.clientHeight || 600, size: 'stretch', minWidth: 80, maxWidth: 3000, minHeight: 80, maxHeight: 4000, usePortrait: !spread, autoSize: true, showCover: false, mobileScrollSupport: false, drawShadow: true, flippingTime: 800, useMouseEvents: true, }; } function openFileViewer(file, title, patternId) { if (!file || !file.url) return; const overlay = document.getElementById('patternFileViewerOverlay'); if (!overlay) return; overlay.classList.add('active'); updateViewerCounterBar(); if ('wakeLock' in navigator) { navigator.wakeLock.request('screen').then(wl => { viewerWakeLock = wl; }).catch(() => {}); } document.getElementById('viewerPatternName').textContent = title || file.name || 'Pattern'; // Reset state viewerType = file.type; viewerFileUrl = file.url; viewerPatternId = patternId || null; viewerCurrentPage = 1; viewerTotalPages = 1; viewerZoom = 1.0; viewerPinchStartDist = null; viewerRenderedPages.clear(); viewerPageDivs = []; destroyPageFlip(); viewerPdfDoc = null; const zoomDisplay = document.getElementById('viewerZoomDisplay'); if (zoomDisplay) zoomDisplay.textContent = '100%'; const book = document.getElementById('viewerBook'); if (book) book.innerHTML = ''; const loading = document.getElementById('viewerLoading'); if (loading) { loading.style.display = 'flex'; loading.innerHTML = '

    Loading…

    '; } updateViewerInfo(); if (file.type === 'pdf') { initPdfViewer(file.url); } else if (file.type === 'epub') { initEpubViewer(file.url); } else { if (loading) loading.style.display = 'none'; if (book) book.innerHTML = `
    `; viewerTotalPages = 1; updateViewerInfo(); } } function destroyPageFlip() { if (viewerPageFlip) { try { viewerPageFlip.destroy(); } catch {} viewerPageFlip = null; } // destroy() removes #viewerBook from the DOM — put it back const stage = document.getElementById('viewerStage'); if (stage && !document.getElementById('viewerBook')) { const book = document.createElement('div'); book.id = 'viewerBook'; book.className = 'viewer-book'; stage.insertBefore(book, stage.firstChild); } } function closePatternFileViewer() { const overlay = document.getElementById('patternFileViewerOverlay'); if (overlay) overlay.classList.remove('active'); destroyPageFlip(); viewerPdfDoc = null; viewerPageDivs = []; viewerRenderedPages.clear(); if (viewerWakeLock) { viewerWakeLock.release().catch(() => {}); viewerWakeLock = null; } } async function initPageFlipLib() { if (!window.St) await loadViewerScript('/assets/page-flip.browser.js'); } async function initPdfViewer(url) { try { if (!window.pdfjsLib) { await loadViewerScript('/assets/pdfjs/pdf.min.js'); window.pdfjsLib.GlobalWorkerOptions.workerSrc = '/assets/pdfjs/pdf.worker.min.js'; } await initPageFlipLib(); const doc = await pdfjsLib.getDocument(url).promise; viewerPdfDoc = doc; viewerTotalPages = doc.numPages; const savedPage = viewerSavedPage(doc.numPages); viewerCurrentPage = savedPage; // Build one div per page — content filled lazily const book = document.getElementById('viewerBook'); const stage = document.getElementById('viewerStage'); book.innerHTML = ''; viewerPageDivs = []; for (let i = 0; i < doc.numPages; i++) { const div = document.createElement('div'); div.className = 'viewer-book-page'; book.appendChild(div); viewerPageDivs.push(div); } viewerPageFlip = new window.St.PageFlip(book, viewerPageFlipOptions(stage)); viewerPageFlip.loadFromHTML(viewerPageDivs); viewerPageFlip.on('flip', async (e) => { viewerCurrentPage = e.data + 1; updateViewerInfo(); saveViewerPage(viewerCurrentPage); renderPdfPagesAround(viewerCurrentPage); }); // Render opening pages, then jump if needed await renderPdfPagesAround(savedPage); if (savedPage > 1) viewerPageFlip.turnToPage(savedPage - 1); const loading = document.getElementById('viewerLoading'); if (loading) loading.style.display = 'none'; updateViewerInfo(); } catch (err) { const loading = document.getElementById('viewerLoading'); if (loading) loading.innerHTML = `

    Could not load PDF: ${err.message}

    `; } } async function renderPdfPagesAround(pageNum) { const toRender = [pageNum - 1, pageNum, pageNum + 1, pageNum + 2] .filter(p => p >= 1 && p <= viewerTotalPages && !viewerRenderedPages.has(p)); for (const p of toRender) { await renderPdfPageIntoDiv(p); } } async function renderPdfPageIntoDiv(pageNum) { if (viewerRenderedPages.has(pageNum) || !viewerPdfDoc) return; viewerRenderedPages.add(pageNum); try { const div = viewerPageDivs[pageNum - 1]; if (!div) return; const page = await viewerPdfDoc.getPage(pageNum); const stage = document.getElementById('viewerStage'); const w = stage.clientWidth || 400; const h = stage.clientHeight || 600; const vp0 = page.getViewport({ scale: 1 }); const scale = Math.min(w / vp0.width, h / vp0.height) * viewerZoom; const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; div.innerHTML = ''; div.appendChild(canvas); } catch { viewerRenderedPages.delete(pageNum); } } let viewerEpubPages = []; async function initEpubViewer(url) { try { await initPageFlipLib(); const resp = await fetch(`/api/epub-content?url=${encodeURIComponent(url)}`); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Could not load EPUB'); viewerEpubPages = data.pages; viewerTotalPages = data.pages.length; const savedPage = viewerSavedPage(data.pages.length); viewerCurrentPage = savedPage; const book = document.getElementById('viewerBook'); const stage = document.getElementById('viewerStage'); book.innerHTML = ''; viewerPageDivs = []; for (let i = 0; i < data.pages.length; i++) { const div = document.createElement('div'); div.className = 'viewer-book-page viewer-epub-page'; div.innerHTML = data.pages[i] || ''; book.appendChild(div); viewerPageDivs.push(div); } // EPUB is always single-page — spread layout breaks chapter scrolling const opts = { ...viewerPageFlipOptions(stage, true), mobileScrollSupport: true }; viewerPageFlip = new window.St.PageFlip(book, opts); viewerPageFlip.loadFromHTML(viewerPageDivs); viewerPageFlip.on('flip', (e) => { viewerCurrentPage = e.data + 1; updateViewerInfo(); saveViewerPage(viewerCurrentPage); }); if (savedPage > 1) viewerPageFlip.turnToPage(savedPage - 1); const loading = document.getElementById('viewerLoading'); if (loading) loading.style.display = 'none'; updateViewerInfo(); } catch (err) { const loading = document.getElementById('viewerLoading'); if (loading) loading.innerHTML = `

    Could not load EPUB: ${err.message}

    `; } } function viewerNavigate(delta) { if (!viewerPageFlip) return; const newPage = viewerCurrentPage + delta; if (newPage < 1 || newPage > viewerTotalPages) return; if (delta > 0) { viewerPageFlip.flipNext('bottom'); if (viewerType === 'pdf') renderPdfPagesAround(newPage); } else { viewerPageFlip.flipPrev('bottom'); if (viewerType === 'pdf') renderPdfPagesAround(newPage); } } function updateViewerInfo() { const info = document.getElementById('viewerPageInfo'); if (info) { if (viewerTotalPages > 1 || viewerType === 'epub') { info.textContent = `${viewerCurrentPage} / ${viewerTotalPages}`; } else { info.textContent = ''; } } const prev = document.getElementById('viewerPrevBtn'); const next = document.getElementById('viewerNextBtn'); const atStart = viewerCurrentPage <= 1; const atEnd = viewerType !== 'epub' && viewerCurrentPage >= viewerTotalPages; if (prev) prev.disabled = atStart; if (next) next.disabled = atEnd; } // Viewer: tap page indicator to jump function viewerJumpToPage() { if (viewerTotalPages <= 1) return; const input = document.createElement('input'); input.type = 'number'; input.min = 1; input.max = viewerTotalPages; input.value = viewerCurrentPage; input.style.cssText = 'width:60px;text-align:center;font-size:1rem;border:1px solid #555;border-radius:6px;background:#222;color:#eee;padding:4px 6px;'; const info = document.getElementById('viewerPageInfo'); if (!info) return; info.replaceWith(input); input.select(); const commit = () => { const val = Math.max(1, Math.min(viewerTotalPages, parseInt(input.value) || viewerCurrentPage)); const newInfo = document.createElement('span'); newInfo.id = 'viewerPageInfo'; newInfo.className = 'viewer-page-info'; newInfo.style.cursor = 'pointer'; newInfo.title = 'Tap to jump to page'; newInfo.onclick = viewerJumpToPage; input.replaceWith(newInfo); if (val !== viewerCurrentPage) { viewerCurrentPage = val; if (viewerPageFlip) viewerPageFlip.turnToPage(val - 1); if (viewerType === 'pdf') renderPdfPagesAround(val); saveViewerPage(val); updateViewerInfo(); } else { updateViewerInfo(); } }; input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { commit(); } }); input.addEventListener('blur', commit); } // Viewer: zoom controls function viewerZoomChange(delta) { viewerZoom = Math.max(0.5, Math.min(4.0, +(viewerZoom + delta).toFixed(2))); applyViewerZoom(); } function viewerZoomReset() { viewerZoom = 1.0; applyViewerZoom(); } function applyViewerZoom() { const zoomDisplay = document.getElementById('viewerZoomDisplay'); if (zoomDisplay) zoomDisplay.textContent = Math.round(viewerZoom * 100) + '%'; const book = document.getElementById('viewerBook'); const stage = document.getElementById('viewerStage'); if (!book || !stage) return; if (viewerZoom === 1) { book.style.transform = ''; book.style.transformOrigin = ''; stage.style.overflow = 'hidden'; } else { book.style.transform = `scale(${viewerZoom})`; book.style.transformOrigin = 'top center'; stage.style.overflow = 'auto'; } } // Viewer: keyboard + swipe navigation document.addEventListener('keydown', (e) => { const overlay = document.getElementById('patternFileViewerOverlay'); if (!overlay || !overlay.classList.contains('active')) return; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') viewerNavigate(1); else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') viewerNavigate(-1); else if (e.key === 'Escape') closePatternFileViewer(); }); // Pinch-to-zoom (StPageFlip handles swipe navigation natively) document.addEventListener('touchstart', (e) => { const overlay = document.getElementById('patternFileViewerOverlay'); if (!overlay || !overlay.classList.contains('active')) return; if (e.touches.length === 2) { viewerPinchStartDist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); viewerPinchStartZoom = viewerZoom; } else { viewerPinchStartDist = null; } }, { passive: true }); document.addEventListener('touchmove', (e) => { const overlay = document.getElementById('patternFileViewerOverlay'); if (!overlay || !overlay.classList.contains('active')) return; if (e.touches.length === 2 && viewerPinchStartDist !== null) { const dist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); const newZoom = Math.max(0.5, Math.min(4.0, viewerPinchStartZoom * (dist / viewerPinchStartDist))); if (Math.abs(newZoom - viewerZoom) > 0.03) { viewerZoom = +newZoom.toFixed(2); applyViewerZoom(); } } }, { passive: true }); document.addEventListener('touchend', () => { viewerPinchStartDist = null; }, { passive: true }); // --- Drag-and-drop pattern reference --- const IMPORTABLE_EXTS = /\.(pdf|epub|jpg|jpeg|png|webp|gif|txt|md)$/i; function isImportableFile(file) { if (!file) return false; if (file.type.startsWith('image/') || file.type === 'application/pdf' || file.type === 'application/epub+zip') return true; return IMPORTABLE_EXTS.test(file.name); } // Handle ?reset= links from password-reset emails (function handleResetParam() { const params = new URLSearchParams(window.location.search); const resetToken = params.get('reset'); if (!resetToken) return; // Strip the param from the URL without reloading const url = new URL(window.location.href); url.searchParams.delete('reset'); history.replaceState(null, '', url.toString()); // Open modal on reset tab and pre-fill the token openAuthModal(); setAuthMode('reset'); const tokenInput = document.getElementById('resetToken'); if (tokenInput) tokenInput.value = resetToken; })(); document.addEventListener('dragover', (e) => { if ([...e.dataTransfer.items].some(i => i.kind === 'file')) e.preventDefault(); }); document.addEventListener('drop', (e) => { const file = e.dataTransfer?.files?.[0]; if (!isImportableFile(file)) return; e.preventDefault(); doUploadAndView(file); });