4075 lines
164 KiB
JavaScript
4075 lines
164 KiB
JavaScript
// --- Data Init & Colors ---
|
||
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
|
||
let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || [];
|
||
if (!Array.isArray(patterns)) patterns = [];
|
||
let auth = {
|
||
token: localStorage.getItem('authToken') || '',
|
||
email: '',
|
||
isAdmin: false,
|
||
status: 'unknown',
|
||
mode: 'login'
|
||
};
|
||
let pendingUsers = [];
|
||
let saveFlashTimer = null;
|
||
const crochetAbbrev = [
|
||
{ code: 'alt', desc: 'alternate' },
|
||
{ code: 'approx', desc: 'approximately' },
|
||
{ code: 'beg', desc: 'begin/beginning' },
|
||
{ code: 'bet', desc: 'between' },
|
||
{ code: 'bl/blo', desc: 'back loop/back loop only' },
|
||
{ code: 'bo', desc: 'bobble' },
|
||
{ code: 'bp', desc: 'back post' },
|
||
{ code: 'bpdc', desc: 'back post double crochet' },
|
||
{ code: 'bpdtr', desc: 'back post double treble crochet' },
|
||
{ code: 'bphdc', desc: 'back post half double crochet' },
|
||
{ code: 'bpsc', desc: 'back post single crochet' },
|
||
{ code: 'bptr', desc: 'back post treble crochet' },
|
||
{ code: 'cc', desc: 'contrasting color' },
|
||
{ code: 'ch', desc: 'chain stitch' },
|
||
{ code: 'ch-sp', desc: 'chain space' },
|
||
{ code: 'cl', desc: 'cluster' },
|
||
{ code: 'cont', desc: 'continue' },
|
||
{ code: 'dc', desc: 'double crochet' },
|
||
{ code: 'dc2tog', desc: 'double crochet 2 stitches together' },
|
||
{ code: 'dec', desc: 'decrease' },
|
||
{ code: 'dtr', desc: 'double treble crochet' },
|
||
{ code: 'edc', desc: 'extended double crochet' },
|
||
{ code: 'ehdc', desc: 'extended half double crochet' },
|
||
{ code: 'esc', desc: 'extended single crochet' },
|
||
{ code: 'etr', desc: 'extended treble crochet' },
|
||
{ code: 'fl/flo', desc: 'front loop/front loop only' },
|
||
{ code: 'foll', desc: 'following' },
|
||
{ code: 'fp', desc: 'front post' },
|
||
{ code: 'fpdc', desc: 'front post double crochet' },
|
||
{ code: 'fpdtr', desc: 'front post double treble crochet' },
|
||
{ code: 'fphdc', desc: 'front post half double crochet' },
|
||
{ code: 'fpsc', desc: 'front post single crochet' },
|
||
{ code: 'fptr', desc: 'front post treble crochet' },
|
||
{ code: 'hdc', desc: 'half double crochet' },
|
||
{ code: 'hdc2tog', desc: 'half double crochet 2 stitches together' },
|
||
{ code: 'inc', desc: 'increase' },
|
||
{ code: 'lp', desc: 'loop' },
|
||
{ code: 'm', desc: 'marker' },
|
||
{ code: 'mc', desc: 'main circle' },
|
||
{ code: 'pat', desc: 'pattern' },
|
||
{ code: 'pc', desc: 'popcorn stitch' },
|
||
{ code: 'pm', desc: 'place marker' },
|
||
{ code: 'prev', desc: 'previous' },
|
||
{ code: 'ps/puff', desc: 'puff stitch' },
|
||
{ code: 'rem', desc: 'remaining' },
|
||
{ code: 'rep', desc: 'repeat' },
|
||
{ code: 'rnd', desc: 'round' },
|
||
{ code: 'rs', desc: 'right side' },
|
||
{ code: 'sc', desc: 'single crochet' },
|
||
{ code: 'sc2tog', desc: 'single crochet 2 stitches together' },
|
||
{ code: 'sh', desc: 'shell' },
|
||
{ code: 'sk', desc: 'skip' },
|
||
{ code: 'sl st', desc: 'slip stitch' },
|
||
{ code: 'sm/sl m', desc: 'slip marker' },
|
||
{ code: 'sp', desc: 'space' },
|
||
{ code: 'st', desc: 'stitch' },
|
||
{ code: 'tbl', desc: 'through back loop' },
|
||
{ code: 'tch/t-ch', desc: 'turning chain' },
|
||
{ code: 'tog', desc: 'together' },
|
||
{ code: 'tr', desc: 'treble crochet' },
|
||
{ code: 'tr2tog', desc: 'treble crochet 2 stitches together' },
|
||
{ code: 'trtr', desc: 'triple treble crochet' },
|
||
{ code: 'ws', desc: 'wrong side' },
|
||
{ code: 'yo', desc: 'yarn over' },
|
||
{ code: 'yoh', desc: 'yarn over hook' },
|
||
// Tunisian
|
||
{ code: 'etss', desc: 'extended Tunisian simple stitch' },
|
||
{ code: 'fwp', desc: 'forward pass' },
|
||
{ code: 'retp', desc: 'return pass' },
|
||
{ code: 'tdc', desc: 'Tunisian double crochet' },
|
||
{ code: 'tfs', desc: 'Tunisian full stitch' },
|
||
{ code: 'thdc', desc: 'Tunisian half double crochet' },
|
||
{ code: 'tks', desc: 'Tunisian knit stitch' },
|
||
{ code: 'tps', desc: 'Tunisian purl stitch' },
|
||
{ code: 'trs', desc: 'Tunisian reverse stitch' },
|
||
{ code: 'tsc', desc: 'Tunisian single crochet' },
|
||
{ code: 'tss', desc: 'Tunisian simple stitch' },
|
||
{ code: 'tslst', desc: 'Tunisian slip stitch' },
|
||
{ code: 'ttr', desc: 'Tunisian treble crochet' },
|
||
{ code: 'ttw', desc: 'Tunisian twisted' }
|
||
];
|
||
|
||
const knitAbbrev = [
|
||
{ code: 'alt', desc: 'alternate' },
|
||
{ code: 'approx', desc: 'approximately' },
|
||
{ code: 'beg', desc: 'beginning' },
|
||
{ code: 'bet', desc: 'between' },
|
||
{ code: 'BO', desc: 'bind off' },
|
||
{ code: 'byo', desc: 'backward yarn over' },
|
||
{ code: 'CC', desc: 'contrasting color' },
|
||
{ code: 'cn', desc: 'cable needle' },
|
||
{ code: 'CO', desc: 'cast on' },
|
||
{ code: 'cont', desc: 'continue' },
|
||
{ code: 'dec', desc: 'decrease' },
|
||
{ code: 'dpn', desc: 'double-pointed needles' },
|
||
{ code: 'foll', desc: 'follow' },
|
||
{ code: 'inc', desc: 'increase' },
|
||
{ code: 'k', desc: 'knit' },
|
||
{ code: 'k1B', desc: 'knit stitch in row below' },
|
||
{ code: 'kfb', desc: 'knit 1 front and back (inc)' },
|
||
{ code: 'ksp', desc: 'knit 1, slip back, pass over (dec)' },
|
||
{ code: 'k2tog', desc: 'knit 2 together (dec)' },
|
||
{ code: 'kwise', desc: 'knitwise' },
|
||
{ code: 'LH', desc: 'left hand' },
|
||
{ code: 'lp', desc: 'loop' },
|
||
{ code: 'm', desc: 'marker' },
|
||
{ code: 'M1', desc: 'make one knitwise (inc)' },
|
||
{ code: 'M1R', desc: 'make one right (inc)' },
|
||
{ code: 'M1L', desc: 'make one left (inc)' },
|
||
{ code: 'M1p', desc: 'make one purlwise (inc)' },
|
||
{ code: 'M1rp', desc: 'make one right purlwise (inc)' },
|
||
{ code: 'M1lp', desc: 'make one left purlwise (inc)' },
|
||
{ code: 'MC', desc: 'main color' },
|
||
{ code: 'p', desc: 'purl' },
|
||
{ code: 'pat', desc: 'pattern' },
|
||
{ code: 'pfb', desc: 'purl front and back (inc)' },
|
||
{ code: 'pm', desc: 'place marker' },
|
||
{ code: 'p2tog', desc: 'purl 2 together (dec)' },
|
||
{ code: 'prev', desc: 'previous' },
|
||
{ code: 'psso', desc: 'pass slipped stitch over' },
|
||
{ code: 'p2sso', desc: 'pass 2 slipped stitches over' },
|
||
{ code: 'pwise', desc: 'purlwise' },
|
||
{ code: 'rem', desc: 'remaining' },
|
||
{ code: 'rep', desc: 'repeat' },
|
||
{ code: 'rev St st', desc: 'reverse stockinette stitch' },
|
||
{ code: 'RH', desc: 'right hand' },
|
||
{ code: 'rnd', desc: 'round' },
|
||
{ code: 'RS', desc: 'right side' },
|
||
{ code: 'SKP', desc: 'slip 1 k-wise, k1, pass slipped over (dec)' },
|
||
{ code: 'SK2P', desc: 'slip 1 k-wise, k2tog, pass slipped over (dec)' },
|
||
{ code: 'sl', desc: 'slip' },
|
||
{ code: 'sl1k', desc: 'slip 1 knitwise' },
|
||
{ code: 'sl1p', desc: 'slip 1 purlwise' },
|
||
{ code: 'sl st', desc: 'slip stitch' },
|
||
{ code: 'sm', desc: 'slip marker' },
|
||
{ code: 'ssk', desc: 'slip 2 k-wise, k through back loops (dec)' },
|
||
{ code: 'ssp', desc: 'slip 2 k-wise, return, p through back loops (dec)' },
|
||
{ code: 'sssk', desc: 'slip 3 k-wise, k through back loops (double dec)' },
|
||
{ code: 'sssp', desc: 'slip 3 k-wise, return, p through back loops (double dec)' },
|
||
{ code: 'S2KP2', desc: 'slip 2 as if to k2tog, k1, pass 2 over (double dec)' },
|
||
{ code: 'SSPP2', desc: 'slip 2 k-wise, return, p2tog tbl, p1, pass 2 over (double dec)' },
|
||
{ code: 'st', desc: 'stitch' },
|
||
{ code: 'St st', desc: 'stockinette stitch' },
|
||
{ code: 'tbl', desc: 'through back loop' },
|
||
{ code: 'tfl', desc: 'through front loop' },
|
||
{ code: 'tog', desc: 'together' },
|
||
{ code: 'WS', desc: 'wrong side' },
|
||
{ code: 'w&t', desc: 'wrap and turn' },
|
||
{ code: 'wyib', desc: 'with yarn in back' },
|
||
{ code: 'wyif', desc: 'with yarn in front' },
|
||
{ code: 'yb', desc: 'yarn back' },
|
||
{ code: 'yfwd/yf', desc: 'yarn forward' },
|
||
{ code: 'yo', desc: 'yarn over' },
|
||
{ code: 'yon', desc: 'yarn over needle' },
|
||
{ code: 'yrn', desc: 'yarn round needle' }
|
||
];
|
||
|
||
const crochetBuckets = [
|
||
{ label: 'Core stitches', codes: ['ch', 'sc', 'hdc', 'dc', 'tr', 'sl st'] },
|
||
{ label: 'Shaping & repeats', codes: ['inc', 'dec', 'tog', 'rep', 'rem', 'rnd', 'rs', 'ws', 'tch/t-ch'] },
|
||
{ label: 'Placement & loops', codes: ['fl/flo', 'bl/blo', 'tbl', 'lp', 'sp', 'ch-sp', 'prev'] },
|
||
{ label: 'Post stitches', codes: ['fp', 'fpdc', 'fptr', 'fpsc', 'fphdc', 'fpdtr', 'bp', 'bpdc', 'bptr', 'bpsc', 'bphdc', 'bpdtr'] },
|
||
{ label: 'Extended & special', codes: ['pat', 'pc', 'ps/puff', 'cl', 'sh', 'dtr', 'edc', 'ehdc', 'esc', 'etr', 'tr2tog', 'trtr', 'yo', 'yoh'] },
|
||
{ label: 'Markers & color', codes: ['mc', 'cc', 'm', 'pm', 'sm/sl m'] },
|
||
{ label: 'Tunisian', codes: ['etss', 'fwp', 'retp', 'tdc', 'tfs', 'thdc', 'tks', 'tps', 'trs', 'tsc', 'tss', 'tslst', 'ttr', 'ttw'] },
|
||
{ label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont', 'foll', 'pat', 'prev'] }
|
||
];
|
||
|
||
const knitBuckets = [
|
||
{ label: 'Core stitches', codes: ['k', 'p', 'yo', 'sl st', 'St st', 'rev St st'] },
|
||
{ label: 'Increases', codes: ['inc', 'M1', 'M1R', 'M1L', 'M1p', 'M1rp', 'M1lp', 'kfb', 'pfb'] },
|
||
{ label: 'Decreases', codes: ['dec', 'k2tog', 'ssk', 'SKP', 'SK2P', 'ssp', 'sssk', 'sssp', 'S2KP2', 'SSPP2', 'p2tog', 'psso', 'p2sso', 'ksp', 'tog'] },
|
||
{ label: 'Slips & markers', codes: ['sl', 'sl1k', 'sl1p', 'm', 'pm', 'sm', 'cn', 'dpn'] },
|
||
{ label: 'Yarn moves', codes: ['wyib', 'wyif', 'yfwd/yf', 'yb', 'yon', 'yrn', 'w&t'] },
|
||
{ label: 'Cast on / bind off', codes: ['CO', 'BO'] },
|
||
{ label: 'Color & pattern', codes: ['MC', 'CC', 'pat'] },
|
||
{ label: 'Positioning', codes: ['k1B', 'tbl', 'tfl', 'lp', 'prev'] },
|
||
{ label: 'Rows & sides', codes: ['rep', 'rem', 'RS', 'WS', 'rnd', 'foll'] },
|
||
{ label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont'] }
|
||
];
|
||
|
||
const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end'];
|
||
const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']);
|
||
|
||
function getActiveAbbrevLibrary() {
|
||
const base = patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
|
||
return [...base, ...(patternDraft.customAbbrev || [])];
|
||
}
|
||
|
||
function getAbbrevByCode(code) {
|
||
return crochetAbbrev.find(a => a.code === code) || knitAbbrev.find(a => a.code === code);
|
||
}
|
||
|
||
function buildAbbrevGroups(library, buckets) {
|
||
const index = new Map(library.map(item => [item.code, item]));
|
||
const used = new Set();
|
||
const groups = [];
|
||
buckets.forEach(bucket => {
|
||
const items = [];
|
||
bucket.codes.forEach(code => {
|
||
const item = index.get(code);
|
||
if (item && !used.has(code)) {
|
||
items.push(item);
|
||
used.add(code);
|
||
}
|
||
});
|
||
if (items.length) groups.push({ label: bucket.label, items });
|
||
});
|
||
const leftover = library.filter(item => !used.has(item.code));
|
||
if (leftover.length) groups.push({ label: 'Other', items: leftover });
|
||
return groups;
|
||
}
|
||
|
||
function getAbbrevGroups() {
|
||
const library = getActiveAbbrevLibrary();
|
||
const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets;
|
||
return buildAbbrevGroups(library, buckets);
|
||
}
|
||
|
||
function getCoreCodes() {
|
||
const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets;
|
||
const core = buckets.find(b => b.label === 'Core stitches');
|
||
return core ? core.codes : [];
|
||
}
|
||
|
||
function getPatternButtonCodes() {
|
||
const base = (patternDraft.abbrevSelection && patternDraft.abbrevSelection.length)
|
||
? patternDraft.abbrevSelection
|
||
: (patternDraft.mode === 'knit'
|
||
? ['k', 'p', 'yo', 'k2tog', 'ssk', 'sl st']
|
||
: ['ch', 'sc', 'hdc', 'dc', 'tr', 'inc', 'dec', 'sl st', 'sk', 'rep']);
|
||
const merged = [...base, ...repeatTokens];
|
||
return Array.from(new Set(merged));
|
||
}
|
||
const yarnWeights = [
|
||
{ val: '0', label: 'Lace', desc: 'Fingering' },
|
||
{ val: '1', label: 'Super Fine', desc: 'Sock' },
|
||
{ val: '2', label: 'Fine', desc: 'Sport' },
|
||
{ val: '3', label: 'Light', desc: 'DK' },
|
||
{ val: '4', label: 'Medium', desc: 'Worsted' },
|
||
{ val: '5', label: 'Bulky', desc: 'Chunky' },
|
||
{ val: '6', label: 'Super Bulky', desc: 'Roving' },
|
||
{ val: '7', label: 'Jumbo', desc: 'Giant' }
|
||
];
|
||
|
||
function normalizePatternDraft(d = {}) {
|
||
const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '', collapsed: false, rowDone: {} };
|
||
const base = {
|
||
mode: 'crochet',
|
||
currentRow: 1,
|
||
line: '',
|
||
output: '',
|
||
meta: { title: '', designer: '' },
|
||
materials: '',
|
||
gauge: '',
|
||
gaugeSts: '',
|
||
gaugeRows: '',
|
||
size: '',
|
||
abbrev: '',
|
||
abbrevSelection: [],
|
||
customAbbrev: [],
|
||
stitches: '',
|
||
notes: '',
|
||
steps: [],
|
||
palette: [],
|
||
yarns: [],
|
||
hooks: [],
|
||
finishedPhotos: [],
|
||
previewSize: 'full',
|
||
savedAt: '',
|
||
sourceId: ''
|
||
};
|
||
const merged = { ...base, ...d };
|
||
merged.meta = { ...base.meta, ...(d.meta || {}) };
|
||
merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : [];
|
||
merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : [];
|
||
merged.palette = Array.isArray(merged.palette) ? merged.palette : [];
|
||
merged.yarns = Array.isArray(merged.yarns) ? merged.yarns : [];
|
||
merged.hooks = Array.isArray(merged.hooks) ? merged.hooks : [];
|
||
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;
|
||
|
||
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,
|
||
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();
|
||
}
|
||
|
||
function setPatternMode(mode) {
|
||
patternDraft.mode = mode;
|
||
persistPatternDraft();
|
||
if (!document.querySelector('.pattern-overlay')) return;
|
||
document.querySelectorAll('.pattern-mode').forEach(btn => {
|
||
btn.classList.toggle('is-active', btn.dataset.mode === mode);
|
||
});
|
||
renderAbbrevChecklist();
|
||
renderSteps();
|
||
}
|
||
|
||
function loadDefaultAbbrev() {
|
||
patternDraft.abbrevSelection = getCoreCodes();
|
||
updateAbbrevFromSelection();
|
||
renderAbbrevChecklist();
|
||
}
|
||
|
||
function bindPatternInputs() {
|
||
const titleEl = document.getElementById('patternTitle');
|
||
const designerEl = document.getElementById('patternDesigner');
|
||
const materialsEl = document.getElementById('patternMaterials');
|
||
const gaugeEl = document.getElementById('patternGauge');
|
||
const gaugeStsEl = document.getElementById('patternGaugeSts');
|
||
const gaugeRowsEl = document.getElementById('patternGaugeRows');
|
||
const gaugeHookEl = document.getElementById('patternGaugeHook');
|
||
const sizeEl = document.getElementById('patternSize');
|
||
const abbrevEl = document.getElementById('patternAbbrev');
|
||
const stitchesEl = document.getElementById('patternStitches');
|
||
const notesEl = document.getElementById('patternNotes');
|
||
const outputEl = document.getElementById('patternOutput');
|
||
if (titleEl) titleEl.addEventListener('input', e => { updateMetaField('title', e.target.value); });
|
||
if (designerEl) designerEl.addEventListener('input', e => { updateMetaField('designer', e.target.value); });
|
||
if (materialsEl) materialsEl.addEventListener('input', e => { updatePatternField('materials', e.target.value); });
|
||
if (gaugeEl) gaugeEl.addEventListener('input', e => { updatePatternField('gauge', e.target.value); });
|
||
if (gaugeStsEl) gaugeStsEl.addEventListener('input', e => { updatePatternField('gaugeSts', e.target.value); });
|
||
if (gaugeRowsEl) gaugeRowsEl.addEventListener('input', e => { updatePatternField('gaugeRows', e.target.value); });
|
||
if (gaugeHookEl) gaugeHookEl.addEventListener('input', e => { updatePatternField('gaugeHook', e.target.value); });
|
||
if (sizeEl) sizeEl.addEventListener('input', e => { updatePatternField('size', e.target.value); });
|
||
if (abbrevEl) abbrevEl.addEventListener('input', e => { updatePatternField('abbrev', e.target.value); });
|
||
if (stitchesEl) stitchesEl.addEventListener('input', e => { updatePatternField('stitches', e.target.value); });
|
||
if (notesEl) notesEl.addEventListener('input', e => { updatePatternField('notes', e.target.value); });
|
||
if (outputEl) outputEl.addEventListener('input', e => {
|
||
patternDraft.output = e.target.value;
|
||
const lines = patternDraft.output.split('\n').filter(l => l.trim() !== '');
|
||
patternDraft.currentRow = Math.max(1, lines.length + 1);
|
||
persistPatternDraft();
|
||
});
|
||
}
|
||
|
||
function syncPatternUI() {
|
||
setPatternMode(patternDraft.mode);
|
||
const titleEl = document.getElementById('patternTitle');
|
||
const designerEl = document.getElementById('patternDesigner');
|
||
const materialsEl = document.getElementById('patternMaterials');
|
||
const gaugeEl = document.getElementById('patternGauge');
|
||
const gaugeStsEl = document.getElementById('patternGaugeSts');
|
||
const gaugeRowsEl = document.getElementById('patternGaugeRows');
|
||
const sizeEl = document.getElementById('patternSize');
|
||
const abbrevEl = document.getElementById('patternAbbrev');
|
||
const stitchesEl = document.getElementById('patternStitches');
|
||
const notesEl = document.getElementById('patternNotes');
|
||
if (titleEl) titleEl.value = patternDraft.meta.title;
|
||
if (designerEl) designerEl.value = patternDraft.meta.designer;
|
||
if (materialsEl) materialsEl.value = patternDraft.materials;
|
||
if (gaugeEl) gaugeEl.value = patternDraft.gauge;
|
||
if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts;
|
||
if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows;
|
||
if (sizeEl) sizeEl.value = patternDraft.size;
|
||
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
|
||
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
|
||
if (notesEl) notesEl.value = patternDraft.notes;
|
||
|
||
renderYarnList();
|
||
renderHookList();
|
||
renderFinishedPhotoList();
|
||
renderSteps();
|
||
renderAbbrevSummary();
|
||
}
|
||
|
||
function renderFinishedPhotoList() {
|
||
const el = document.getElementById('finishedPhotoList');
|
||
if (!el) return;
|
||
if (!Array.isArray(patternDraft.finishedPhotos)) patternDraft.finishedPhotos = [];
|
||
if (patternDraft.finishedPhotos.length === 0) {
|
||
el.innerHTML = `<p class="muted">No finished photos yet.</p>`;
|
||
return;
|
||
}
|
||
el.innerHTML = patternDraft.finishedPhotos.map((photo, idx) => `
|
||
<div class="finished-photo-row">
|
||
${auth.token ? `
|
||
<button class="btn-icon-small" onclick="uploadFinishedPhoto(${idx})" title="Upload photo"><i class="fa-solid fa-upload"></i></button>
|
||
${photo.url ? `<button class="btn-icon-small danger" onclick="removeFinishedPhoto(${idx})" title="Remove photo"><i class="fa-solid fa-trash"></i></button>` : ''}
|
||
<span class="muted small">${photo.url ? 'Replace or remove.' : 'Upload a finished photo.'}</span>
|
||
` : '<span class="muted small">Sign in to upload photos.</span>'}
|
||
</div>
|
||
${photo.url ? `<div class="step-img-preview compact"><img src="${resolveImageUrl(photo.url)}" alt="Finished photo"></div>` : ''}
|
||
`).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 = `<button class="secondary small" onclick="addYarn()">+ Add Yarn</button>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = patternDraft.yarns.map((y, idx) => `
|
||
<div class="yarn-item-card" style="border-left: 5px solid ${y.color || 'var(--border)'}">
|
||
<div class="yarn-header">
|
||
<span class="yarn-idx">Yarn ${String.fromCharCode(65 + idx)}</span>
|
||
<div style="display:flex; gap:8px;">
|
||
<label class="color-picker-label" style="background:${y.color || '#e0e0e0'}" title="Pick Color">
|
||
<input type="color" value="${y.color || '#e0e0e0'}" onchange="updateYarn(${idx}, 'color', this.value)">
|
||
</label>
|
||
<button class="btn-icon-small danger" onclick="removeYarn(${idx})">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="yarn-weight-selector small">
|
||
${yarnWeights.map(w => `
|
||
<label class="yarn-pill small">
|
||
<input type="radio" name="yarnWeight_${idx}" value="${w.val}" ${y.weight === w.val ? 'checked' : ''} onchange="updateYarn(${idx}, 'weight', '${w.val}')">
|
||
<span class="yarn-content">
|
||
<span class="yarn-num">${w.val}</span>
|
||
<span class="yarn-label">${w.label}</span>
|
||
</span>
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
<input type="text" class="yarn-note-input" value="${y.note || ''}" placeholder="Brand / Fiber / Note" oninput="updateYarn(${idx}, 'note', this.value)">
|
||
</div>
|
||
`).join('') + `<button class="secondary small" onclick="addYarn()" style="margin-top:8px;">+ Add Another Yarn</button>`;
|
||
}
|
||
|
||
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 = `<button class="secondary small" onclick="addHook()">+ Add Hook</button>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = patternDraft.hooks.map((h, idx) => `
|
||
<div class="hook-item-row">
|
||
<input type="text" class="hook-size-input" value="${h.size || ''}" placeholder="Size (e.g. 4.0mm)" oninput="updateHook(${idx}, 'size', this.value)">
|
||
<input type="text" class="hook-note-input" value="${h.note || ''}" placeholder="Use (e.g. Body)" oninput="updateHook(${idx}, 'note', this.value)">
|
||
<button class="btn-icon-small danger" onclick="removeHook(${idx})">×</button>
|
||
</div>
|
||
`).join('') + `<button class="secondary small" onclick="addHook()" style="margin-top:6px;">+ Add Hook</button>`;
|
||
}
|
||
|
||
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) => `
|
||
<div class="palette-swatch" style="background:${c}" onclick="removePatternColor(${i})" title="Remove color"></div>
|
||
`).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 printFriendly = await showConfirm({
|
||
title: 'Export theme',
|
||
text: 'Use a print-friendly light theme?',
|
||
confirmText: 'Print-friendly',
|
||
cancelText: 'App-themed'
|
||
});
|
||
const w = window.open('', '_blank');
|
||
if (!w) 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 ? `<span class="swatch" style="background:${y.color}"></span>` : '';
|
||
return `<li><strong>${y.note || 'Yarn'}</strong>${weightLabel ? ` — ${weightLabel}` : ''}${swatch}</li>`;
|
||
});
|
||
const hookItems = (patternDraft.hooks || []).map(h => `<li>${h.size || ''}${h.note ? ` (${h.note})` : ''}</li>`);
|
||
const paletteItems = (patternDraft.palette || []).map(c => `<li><span class="swatch" style="background:${c}"></span></li>`);
|
||
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 = '<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">';
|
||
|
||
const sections = [];
|
||
if ((patternDraft.materials || '').trim()) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Materials</div>
|
||
<pre>${patternDraft.materials}</pre>
|
||
</div>
|
||
`);
|
||
}
|
||
if (yarnItems.length || hookItems.length || paletteItems.length) {
|
||
const parts = [];
|
||
if (yarnItems.length) parts.push(`<ul class="pdf-list">${yarnItems.join('')}</ul>`);
|
||
if (hookItems.length) parts.push(`<ul class="pdf-list">${hookItems.join('')}</ul>`);
|
||
if (paletteItems.length) parts.push(`<ul class="pdf-list palette-list">${paletteItems.join('')}</ul>`);
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Yarns, Hooks, Palette</div>
|
||
${parts.join('')}
|
||
</div>
|
||
`);
|
||
}
|
||
if (gaugeBlock.trim() || (patternDraft.gauge || '').trim() || (patternDraft.size || '').trim()) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Gauge / Size</div>
|
||
<pre>${[gaugeBlock, patternDraft.gauge || '', patternDraft.size || ''].filter(Boolean).join('\n')}</pre>
|
||
</div>
|
||
`);
|
||
}
|
||
if (patternDraft.finishedPhotos && patternDraft.finishedPhotos.length) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Finished Photos</div>
|
||
${(patternDraft.finishedPhotos || []).map(p => `<img src="${resolveImageUrl(p.url)}" style="max-width:220px; max-height:220px; margin:6px; border-radius:8px; border:1px solid ${themeText}20;">`).join('')}
|
||
</div>
|
||
`);
|
||
}
|
||
if ((abbrevBlock || '').trim()) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Abbreviations</div>
|
||
<pre>${abbrevBlock}</pre>
|
||
</div>
|
||
`);
|
||
}
|
||
if ((patternDraft.stitches || '').trim()) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Stitch Guide</div>
|
||
<pre>${patternDraft.stitches}</pre>
|
||
</div>
|
||
`);
|
||
}
|
||
if ((patternDraft.steps || []).length) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Steps</div>
|
||
${(patternDraft.steps || []).map((s, i) => {
|
||
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('<br>');
|
||
const note = s.note ? `<br><strong>Note:</strong> ${s.note}` : '';
|
||
const img = s.image ? `<br><img src="${resolveImageUrl(s.image)}" style="max-width:300px; max-height:300px; margin-top:10px; border-radius:8px;">` : '';
|
||
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows || '<em>No rows yet.</em>'}${note}${img}</div>`;
|
||
}).join('<hr>')}
|
||
</div>
|
||
`);
|
||
}
|
||
if ((patternDraft.output || '').trim()) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Rows</div>
|
||
<pre>${patternDraft.output}</pre>
|
||
</div>
|
||
`);
|
||
}
|
||
if ((patternDraft.notes || '').trim()) {
|
||
sections.push(`
|
||
<div class="section">
|
||
<div class="section-title">Notes</div>
|
||
<pre>${patternDraft.notes}</pre>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
const html = `
|
||
<html>
|
||
<head>
|
||
<title>${patternDraft.meta.title || 'Pattern'} - PDF</title>
|
||
${fontLink}
|
||
<style>
|
||
@media print {
|
||
body { -webkit-print-color-adjust: exact; }
|
||
.pdf-footer { position: fixed; }
|
||
}
|
||
body { font-family: 'Nunito', 'Segoe UI', sans-serif; background: ${themeBg}; color: ${themeText}; }
|
||
h1, h2, h3 { font-family: 'Playfair Display', Georgia, serif; padding: 20px; margin-bottom: 5px }
|
||
.section { margin: 45px; padding: 10px; border: 1px solid ${accent}30; border-radius: 10px; background: ${printFriendly ? '#ffffff' : 'transparent'}; }
|
||
.section-title { font-size: 1.1rem; margin-bottom: 6px; border-bottom: 1px solid ${accent}50; padding-bottom: 4px; color: ${accent}; }
|
||
pre { white-space: pre-wrap; background: ${printFriendly ? '#ffffff' : themeBg}; border: 1px solid ${themeText}20; padding: 10px; border-radius: 8px; }
|
||
hr { border: none; border-top: 1px solid ${themeText}20; margin: 10px 0; }
|
||
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
.pdf-list { margin: 0 0 10px; padding-left: 18px; }
|
||
.pdf-list li { margin-bottom: 4px; }
|
||
.swatch {
|
||
display: inline-block;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
border: 1px solid ${themeText}30;
|
||
margin-left: 8px;
|
||
vertical-align: middle;
|
||
}
|
||
.palette-list { display: flex; gap: 8px; list-style: none; padding-left: 0; }
|
||
.palette-list li { margin-bottom: 0; }
|
||
.pdf-footer {
|
||
position: fixed;
|
||
bottom: -460;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1000px;
|
||
background: url('${mushroomUrl}') center bottom / 1250px auto no-repeat;
|
||
opacity: ${printFriendly ? '0.35' : '0.6'};
|
||
pointer-events: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>${patternDraft.meta.title || 'Pattern'}</h1>
|
||
${patternDraft.meta.designer ? `<h3>${patternDraft.meta.designer}</h3>` : ''}
|
||
${sections.join('')}
|
||
<div class="pdf-footer"></div>
|
||
</body>
|
||
</html>
|
||
`;
|
||
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();
|
||
}
|
||
|
||
try { localStorage.setItem('patternActiveTab', tab); } catch (e) {}
|
||
}
|
||
|
||
window.addEventListener('resize', () => {
|
||
const activeBtn = document.querySelector('.nav-item.active');
|
||
if (activeBtn && activeBtn.dataset.tab === 'steps') {
|
||
showPatternTab('steps');
|
||
}
|
||
});
|
||
|
||
function updateMetaField(field, value) {
|
||
patternDraft.meta[field] = value;
|
||
persistPatternDraft();
|
||
}
|
||
|
||
function updatePatternField(field, value) {
|
||
patternDraft[field] = value;
|
||
persistPatternDraft();
|
||
}
|
||
|
||
function renderSteps() {
|
||
const container = document.getElementById('patternSteps');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
|
||
patternDraft.steps.forEach((step, idx) => {
|
||
const card = document.createElement('div');
|
||
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) => `
|
||
<div class="row-item">
|
||
<span class="row-num">${i + 1}.</span>
|
||
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}" placeholder="Row instructions...">
|
||
<button type="button" class="btn-icon-small" onclick="removeStepRow(${idx}, ${i})" title="Remove row">✕</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
card.innerHTML = `
|
||
<div class="step-card-header">
|
||
<div class="step-title-group">
|
||
<span class="step-badge">Step ${idx + 1}</span>
|
||
<span class="step-meta">${rowCount} row${rowCount === 1 ? '' : 's'}</span>
|
||
<input type="text" class="step-title-input" value="${step.title || ''}" data-idx="${idx}" data-field="title" placeholder="Step Title (e.g., Head)">
|
||
</div>
|
||
<div class="step-actions-group">
|
||
<button class="btn-icon-small step-collapse-btn" onclick="toggleStepCollapse(${idx})" title="${isCollapsed ? 'Expand' : 'Collapse'}">
|
||
<i class="fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<button class="btn-icon-small" onclick="moveStep(${idx}, -1)" title="Move Up">↑</button>
|
||
<button class="btn-icon-small" onclick="moveStep(${idx}, 1)" title="Move Down">↓</button>
|
||
<button class="btn-icon-small danger" onclick="deleteStep(${idx})" title="Delete Step"><i class="fa-solid fa-trash"></i></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pattern-step-body">
|
||
<div class="pattern-row-list">${rowList}</div>
|
||
|
||
<div class="pattern-row-editor">
|
||
<div class="editor-bar">
|
||
<textarea data-idx="${idx}" data-field="rowDraft" rows="1" placeholder="Type next row instructions...">${step.rowDraft || ''}</textarea>
|
||
<button class="primary small" onclick="addStepRow(${idx})">Add Row</button>
|
||
</div>
|
||
<div class="pattern-buttons inline-buttons">
|
||
${getPatternButtonCodes().map(tok => `<button type="button" data-tok="${tok}" data-idx="${idx}">${tok}</button>`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="step-extras">
|
||
<label class="field-label-small" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||
<i class="fa-solid fa-caret-right"></i> Extra Info (Note / Image)
|
||
</label>
|
||
<div class="extras-row hidden">
|
||
<input type="text" value="${step.note || ''}" data-idx="${idx}" data-field="note" placeholder="Notes...">
|
||
<div class="image-input-group">
|
||
${auth.token ? `
|
||
<button class="btn-icon-small" onclick="uploadStepImage(${idx})" title="Upload/Change Image"><i class="fa-solid fa-upload"></i></button>
|
||
${step.image ? `<button class="btn-icon-small danger" onclick="removeStepImage(${idx})" title="Remove Image"><i class="fa-solid fa-trash"></i></button>` : ''}
|
||
<span class="muted small">${step.image ? 'Replace or remove the image.' : 'Upload an image for this step.'}</span>
|
||
` : '<span class="muted small">Sign in to upload images.</span>'}
|
||
</div>
|
||
${step.image ? `<div class="step-img-preview compact"><img src="${stepImageUrl}" alt="Step Image"></div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `<button class="primary add-step-btn" onclick="addStep()">+ New Step</button>`;
|
||
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 = `
|
||
<div class="view-card" style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||
<div>
|
||
<h3 class="view-title">${meta.title || 'Pattern'}</h3>
|
||
<p class="view-sub">${meta.designer || ''}</p>
|
||
${mats ? `<h4>Materials</h4><pre>${mats}</pre>` : ''}
|
||
${(gauge || gaugeSts || gaugeRows || displayedHooks || size) ? `<h4>Gauge / Size</h4><pre>${[gaugeSts, gaugeRows, displayedHooks, size, gauge].filter(Boolean).join(' • ')}</pre>` : ''}
|
||
</div>
|
||
<button class="primary small" onclick="startProjectFromDraft()" title="Start a project from this pattern">
|
||
<i class="fa-solid fa-play"></i> Start Project
|
||
</button>
|
||
</div>
|
||
|
||
<div class="view-card">
|
||
<h4>Yarn & Tools</h4>
|
||
${patternDraft.yarns.length ? `<ul class="view-list">${patternDraft.yarns.map(y => `<li><strong>${y.note || 'Yarn'}:</strong> Weight ${y.weight} ${y.color ? `<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${y.color};border:1px solid #ccc;vertical-align:middle;margin-left:4px;"></span>` : ''}</li>`).join('')}</ul>` : ''}
|
||
${patternDraft.hooks.length ? `<p style="margin-top:8px;"><strong>Hooks:</strong> ${patternDraft.hooks.map(h => `${h.size} ${h.note ? `(${h.note})` : ''}`).join(', ')}</p>` : ''}
|
||
${!patternDraft.yarns.length && !patternDraft.hooks.length ? '<p class="muted">No yarn or tools listed.</p>' : ''}
|
||
</div>
|
||
|
||
${patternDraft.finishedPhotos.length ? `
|
||
<div class="view-card">
|
||
<h4>Finished Photos</h4>
|
||
<div class="view-photo-grid">
|
||
${patternDraft.finishedPhotos.map(p => `<img src="${resolveImageUrl(p.url)}" class="view-step-img" alt="Finished photo" onclick="openImagePreview('${resolveImageUrl(p.url)}')">`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<div class="view-card">
|
||
<h4>Key</h4>
|
||
${patternDraft.abbrevSelection.length ? `<div class="view-key-grid">${patternDraft.abbrevSelection.map(c => { const i = getAbbrevByCode(c); return `<div class="key-item"><span class="key-code">${c}</span> <span class="key-sep">–</span> <span class="key-desc">${i ? i.desc : ''}</span></div>`; }).join('')}</div>` : '<p class="muted">No abbreviations selected.</p>'}
|
||
</div>
|
||
|
||
<div class="view-card">
|
||
<h4>Steps</h4>
|
||
<div class="view-steps">
|
||
${steps.map((st, i) => `
|
||
<div class="view-step ${st.finished ? 'is-finished minimized' : ''}">
|
||
<header onclick="toggleViewStepMinimize(this)">
|
||
<div class="title">Step ${i + 1}${st.title ? ': ' + st.title : ''}</div>
|
||
</header>
|
||
<div class="view-step-body">
|
||
<ul class="rows">${(st.rows || []).map((r, idx) => {
|
||
const rowDone = st.rowDone && st.rowDone[idx];
|
||
return `<li class="view-row ${rowDone ? 'is-done' : ''}" data-view-step="${i}" data-view-row="${idx}">Row ${idx + 1}: ${r}</li>`;
|
||
}).join('') || '<li class="muted">No rows yet.</li>'}</ul>
|
||
${st.image ? `<img src="${resolveImageUrl(st.image)}" class="view-step-img" alt="Step Image" onclick="openImagePreview('${resolveImageUrl(st.image)}')">` : ''}
|
||
|
||
<button class="note-toggle-btn" onclick="document.getElementById('view-note-${i}').classList.toggle('active')">
|
||
<i class="fa-regular fa-pen-to-square"></i> ${st.viewNote ? 'Edit Note' : 'Add Note'}
|
||
</button>
|
||
<div id="view-note-${i}" class="view-note-area ${st.viewNote ? 'active' : ''}">
|
||
<textarea data-view-note="${i}" placeholder="Notes for this step...">${st.viewNote || ''}</textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="image-preview-dialog">
|
||
<button class="image-preview-close" type="button" aria-label="Close preview">×</button>
|
||
<img class="image-preview-img" alt="Preview">
|
||
</div>
|
||
`;
|
||
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');
|
||
}
|
||
|
||
function renderPatternLibrary() {
|
||
const lib = document.getElementById('patternLibrary');
|
||
if (!lib) return;
|
||
const visiblePatterns = patterns.filter(p => !p.deleted_at);
|
||
if (!visiblePatterns.length) {
|
||
lib.innerHTML = `<p class="muted">No saved patterns yet. Save your draft to add it here.</p>`;
|
||
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 => {
|
||
if (!term) return true;
|
||
const title = (p.name || p.title || p.draft?.meta?.title || '').toLowerCase();
|
||
const designer = (p.draft?.meta?.designer || '').toLowerCase();
|
||
return title.includes(term) || designer.includes(term);
|
||
})
|
||
.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 = `<p class="muted">No matches found.</p>`;
|
||
return;
|
||
}
|
||
|
||
lib.innerHTML = list.map(p => {
|
||
const subtitle = p.draft?.meta?.designer || '';
|
||
const title = p.name || p.title || p.draft?.meta?.title || 'Pattern';
|
||
const savedAt = p.draft?.savedAt || p.updated_at || '';
|
||
const savedText = savedAt ? `Saved ${new Date(savedAt).toLocaleDateString()}` : 'Not saved yet';
|
||
return `
|
||
<div class="pattern-library-item">
|
||
<div>
|
||
<div class="pattern-lib-title">${title}</div>
|
||
<div class="pattern-lib-subtitle">${subtitle}</div>
|
||
<div class="pattern-lib-meta">${savedText}</div>
|
||
</div>
|
||
<div class="pattern-lib-actions">
|
||
<button class="secondary" onclick="loadPatternFromLibrary('${p.id}')">Load</button>
|
||
<button class="secondary" onclick="startProjectFromLibrary('${p.id}')">Start</button>
|
||
<button class="secondary" onclick="sharePatternFromLibrary('${p.id}')">Share</button>
|
||
<button class="secondary danger" onclick="deletePatternFromLibrary('${p.id}')">Delete</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function savePatternDraft(options = {}) {
|
||
const { silent = false } = options;
|
||
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 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 = `<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = patternDraft.abbrevSelection.map(code => {
|
||
const item = getAbbrevByCode(code);
|
||
return `<span class="abbrev-pill is-selected" style="cursor: default;">
|
||
<span class="code">${code}</span>
|
||
<span class="desc" style="display:none;">${item ? item.desc : ''}</span>
|
||
</span>`;
|
||
}).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 = `
|
||
<div class="abbrev-panel-head">
|
||
<strong>Selected (${selected.size})</strong>
|
||
<span class="muted small">Tap to remove</span>
|
||
</div>
|
||
<div class="abbrev-grid selected-grid"></div>
|
||
`;
|
||
const selectedGrid = selectedPanel.querySelector('.selected-grid');
|
||
const library = getActiveAbbrevLibrary();
|
||
const selectedItems = library.filter(item => selected.has(item.code));
|
||
if (!selectedItems.length) {
|
||
selectedGrid.innerHTML = '<p class="muted small">No stitches selected yet.</p>';
|
||
} 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 = `<div class="abbrev-panel-head"><strong>All stitches</strong></div>`;
|
||
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 = `
|
||
<div class="abbrev-custom-head">Add Custom Abbreviation</div>
|
||
<div class="abbrev-custom-body">
|
||
<div class="abbrev-custom-grid">
|
||
<input id="newAbbrevCode" placeholder="Code (e.g. mc)" class="abbrev-custom-input">
|
||
<input id="newAbbrevDesc" placeholder="Description (e.g. magic circle)" class="abbrev-custom-input">
|
||
</div>
|
||
<button class="abbrev-custom-btn" onclick="addCustomAbbrev()">+ Add Custom</button>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<span class="code">${item.code}</span>
|
||
<span class="desc">${item.desc}</span>
|
||
`;
|
||
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();
|
||
}
|
||
|
||
function openPatternComposer() {
|
||
if (!auth.token) {
|
||
openAuthModal();
|
||
showAlert({ title: 'Sign in required', text: 'Sign in to access the pattern composer.' });
|
||
return;
|
||
}
|
||
if (!patternOverlay) return;
|
||
patternOverlay.classList.add('active');
|
||
syncPatternUI();
|
||
renderPatternLibrary();
|
||
renderPatternView();
|
||
const savedTab = localStorage.getItem('patternActiveTab') || 'steps';
|
||
showPatternTab(savedTab);
|
||
}
|
||
|
||
function closePatternComposer() {
|
||
if (!patternOverlay) return;
|
||
patternOverlay.classList.remove('active');
|
||
}
|
||
let lastSelectedAbbrev = '';
|
||
syncPatternUI();
|
||
bindPatternInputs();
|
||
renderAbbrevChecklist();
|
||
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 = '<div class="spinner">Loading...</div>';
|
||
|
||
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 = '<p class="muted">No users found.</p>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = data.users.map(u => `
|
||
<div class="admin-item">
|
||
<div class="meta">
|
||
<strong>${u.email}</strong>
|
||
<span class="status-subtext">${u.display_name || 'No name'} • ${u.status} ${u.is_admin ? '• Admin' : ''}</span>
|
||
</div>
|
||
<div class="actions">
|
||
${!u.is_admin ? `<button class="modal-btn btn-secondary" onclick="makeAdmin('${u.id}')">Make Admin</button>` : ''}
|
||
${u.status === 'active' ? `<button class="modal-btn btn-cancel" onclick="suspendUser('${u.id}')">Suspend</button>` : ''}
|
||
${u.status === 'suspended' ? `<button class="modal-btn btn-save" onclick="approveUser('${u.id}')">Activate</button>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} catch (err) {
|
||
list.innerHTML = `<p class="danger-text">Error: ${err.message}</p>`;
|
||
}
|
||
}
|
||
|
||
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.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);
|
||
return {
|
||
id: normalized.id,
|
||
title: normalized.title || normalized.name || '',
|
||
slug: normalized.slug || null,
|
||
data: normalized.data || { draft: normalized.draft },
|
||
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,
|
||
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 for a reset token', text: 'If the account exists, a reset token will be sent. If email delivery is not set up, contact your admin.' });
|
||
} 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.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;
|
||
|
||
// --- Sweet-ish Alerts ---
|
||
function removeSwal() {
|
||
const existing = document.querySelector('.swal-overlay');
|
||
if (existing) existing.remove();
|
||
}
|
||
|
||
function showConfirm({ title = 'Are you sure?', text = '', confirmText = 'Yes', cancelText = 'Cancel', danger = false } = {}) {
|
||
return new Promise(resolve => {
|
||
removeSwal();
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'swal-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="swal-dialog">
|
||
<div class="swal-title">${title}</div>
|
||
<div class="swal-text">${text}</div>
|
||
<div class="swal-actions">
|
||
<button class="swal-btn swal-cancel">${cancelText}</button>
|
||
<button class="swal-btn ${danger ? 'swal-danger' : 'swal-confirm'}">${confirmText}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const cancelBtn = overlay.querySelector('.swal-cancel');
|
||
const confirmBtn = overlay.querySelector('.swal-confirm, .swal-danger');
|
||
cancelBtn.onclick = () => { removeSwal(); resolve(false); };
|
||
confirmBtn.onclick = () => { removeSwal(); resolve(true); };
|
||
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 = `
|
||
<div class="swal-dialog">
|
||
<div class="swal-title">${title}</div>
|
||
<div class="swal-text">${text}</div>
|
||
<label class="swal-remember">
|
||
<input type="checkbox" id="swalRememberChoice">
|
||
${rememberText}
|
||
</label>
|
||
<div class="swal-actions">
|
||
<button class="swal-btn swal-cancel">${cancelText}</button>
|
||
<button class="swal-btn ${danger ? 'swal-danger' : 'swal-confirm'}">${confirmText}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const cancelBtn = overlay.querySelector('.swal-cancel');
|
||
const confirmBtn = overlay.querySelector('.swal-confirm, .swal-danger');
|
||
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 = `
|
||
<div class="swal-dialog">
|
||
<div class="swal-title">${title}</div>
|
||
<div class="swal-text">${text}</div>
|
||
<div class="swal-actions">
|
||
<button class="swal-btn swal-confirm">OK</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const okBtn = overlay.querySelector('.swal-confirm');
|
||
okBtn.onclick = () => { removeSwal(); resolve(); };
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(); } });
|
||
document.addEventListener('keydown', function onKey(e) {
|
||
if (e.key === 'Escape') { removeSwal(); resolve(); document.removeEventListener('keydown', onKey); }
|
||
});
|
||
document.body.appendChild(overlay);
|
||
});
|
||
}
|
||
|
||
// --- Theme Logic ---
|
||
let isDarkMode = JSON.parse(localStorage.getItem('crochetDarkMode'));
|
||
if (isDarkMode === null) {
|
||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||
isDarkMode = true;
|
||
} else {
|
||
isDarkMode = false;
|
||
}
|
||
}
|
||
let animationsEnabled = JSON.parse(localStorage.getItem('crochetAnimations'));
|
||
if (animationsEnabled === null) animationsEnabled = true;
|
||
|
||
function applyTheme() {
|
||
const themeBtn = document.getElementById('themeBtn');
|
||
if (isDarkMode) {
|
||
document.body.classList.add('dark-mode');
|
||
if (themeBtn) themeBtn.innerHTML = '<i class="fa-solid fa-moon"></i>';
|
||
} else {
|
||
document.body.classList.remove('dark-mode');
|
||
if (themeBtn) themeBtn.innerHTML = '<i class="fa-solid fa-sun"></i>';
|
||
}
|
||
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 ? '<i class="fa-solid fa-wand-magic-sparkles"></i>' : '<i class="fa-solid fa-ban"></i>';
|
||
motionBtn.title = animationsEnabled ? 'Toggle Animations' : 'Animations disabled';
|
||
}
|
||
|
||
function toggleAnimations() {
|
||
animationsEnabled = !animationsEnabled;
|
||
localStorage.setItem('crochetAnimations', animationsEnabled);
|
||
updateMotionBtn();
|
||
if (!animationsEnabled) {
|
||
stopAmbientDrift();
|
||
document.body.classList.remove('theme-animating');
|
||
} else {
|
||
handleAmbientDrift();
|
||
}
|
||
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 => `
|
||
<button class="color-swatch" style="background:${c}" onclick="setPartColor('${pId}', '${partId}', '${c}'); closeColorPicker();"></button>
|
||
`).join('');
|
||
if (customColorInput) {
|
||
customColorInput.value = part.color || project.color || colors[0];
|
||
customColorInput.oninput = (e) => {
|
||
setPartColor(pId, partId, e.target.value);
|
||
};
|
||
}
|
||
colorOverlay.classList.add('active');
|
||
colorOverlay.dataset.projectId = pId;
|
||
colorOverlay.dataset.partId = partId;
|
||
}
|
||
|
||
function closeColorPicker() {
|
||
if (!colorOverlay) return;
|
||
colorOverlay.classList.remove('active');
|
||
colorGrid.innerHTML = '';
|
||
colorOverlay.dataset.projectId = '';
|
||
colorOverlay.dataset.partId = '';
|
||
}
|
||
|
||
function savePatterns() {
|
||
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
|
||
}
|
||
|
||
function populatePatternSelect() {
|
||
if (!patternPicker || !patternSelect) return;
|
||
const visiblePatterns = patterns.filter(p => !p.deleted_at);
|
||
const hasPatterns = visiblePatterns.length > 0;
|
||
patternPicker.style.display = hasPatterns ? 'block' : 'none';
|
||
patternSelect.innerHTML = '<option value=\"\">No pattern</option>' + visiblePatterns.map(p => {
|
||
const draft = p.draft || p.data?.draft || {};
|
||
const title = p.name || p.title || draft.meta?.title || 'Pattern';
|
||
return `<option value="${p.id}">${title}</option>`;
|
||
}).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 = '<p class="save-subtext">No projects found in file.</p>';
|
||
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 = `
|
||
<input type="checkbox" checked data-import-id="${p.__importKey}">
|
||
<span>${p.name || `Project ${idx + 1}`} <small class="muted">(${partsCount} part${partsCount === 1 ? '' : 's'})</small></span>
|
||
`;
|
||
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 = `
|
||
<input type="checkbox" checked data-id="${p.id}">
|
||
<span>${p.name}</span>
|
||
`;
|
||
saveList.appendChild(item);
|
||
});
|
||
saveOverlay.classList.add('active');
|
||
}
|
||
|
||
function closeSaveModal() {
|
||
if (!saveOverlay) return;
|
||
saveOverlay.classList.remove('active');
|
||
saveList.innerHTML = '';
|
||
}
|
||
|
||
function exportSelected() {
|
||
if (!saveOverlay) return;
|
||
const inputs = saveList.querySelectorAll('input[type="checkbox"]');
|
||
const selectedIds = Array.from(inputs).filter(i => i.checked).map(i => 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 isFocusMode = false;
|
||
const focusBtn = document.getElementById('focusBtn');
|
||
|
||
// --- Migration Check ---
|
||
if (projects.length > 0) {
|
||
let changed = false;
|
||
projects.forEach((p, index) => {
|
||
if (!p.parts) { p.parts = []; changed = true; }
|
||
if (!p.color) { p.color = colors[index % colors.length]; changed = true; }
|
||
const oldIdx = oldColors.indexOf(p.color);
|
||
if (oldIdx !== -1) { p.color = colors[oldIdx % colors.length]; changed = true; }
|
||
if (p.note === undefined) { p.note = ''; changed = true; }
|
||
p.parts.forEach(pt => {
|
||
if (pt.max === undefined) { pt.max = null; changed = true; }
|
||
if (pt.note === undefined) { pt.note = ''; changed = true; }
|
||
if (!pt.color) { pt.color = p.color; changed = true; }
|
||
const oldPartIdx = oldColors.indexOf(pt.color);
|
||
if (oldPartIdx !== -1) { pt.color = colors[oldPartIdx % colors.length]; changed = true; }
|
||
});
|
||
});
|
||
if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); }
|
||
}
|
||
|
||
// --- Core Functions ---
|
||
function save() {
|
||
localStorage.setItem('crochetCounters', JSON.stringify(projects));
|
||
render();
|
||
}
|
||
|
||
async function toggleFocusMode() {
|
||
if (!isFocusMode) {
|
||
try {
|
||
if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
|
||
if ('wakeLock' in navigator) {
|
||
wakeLock = await navigator.wakeLock.request('screen');
|
||
}
|
||
isFocusMode = true;
|
||
focusBtn.classList.add('is-active');
|
||
} catch (err) { showAlert({ title: 'Focus Mode failed', text: err.message }); }
|
||
} else {
|
||
if (document.fullscreenElement) document.exitFullscreen();
|
||
if (wakeLock !== null) { wakeLock.release(); wakeLock = null; }
|
||
isFocusMode = false;
|
||
focusBtn.classList.remove('is-active');
|
||
}
|
||
}
|
||
document.addEventListener('visibilitychange', async () => {
|
||
if (wakeLock !== null && document.visibilityState === 'visible') {
|
||
wakeLock = await navigator.wakeLock.request('screen');
|
||
}
|
||
});
|
||
|
||
// --- Interaction Logic ---
|
||
async function deleteProject(pId) {
|
||
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' };
|
||
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 = '<div class="empty-state">Toadstools & twine await...<br>Tap + to begin a new project.</div>';
|
||
return;
|
||
}
|
||
|
||
const grid = document.createElement('div');
|
||
grid.className = 'projects-grid';
|
||
|
||
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 ? '<i class="fa-solid fa-lock"></i>' : '<i class="fa-solid fa-lock-open"></i>';
|
||
const lockBtnClass = part.locked ? 'btn-lock locked-active' : 'btn-lock';
|
||
const controlsDimmed = (part.locked || part.finished) ? 'dimmed' : '';
|
||
const hideControls = (part.finished || part.minimized) ? 'hidden-controls' : '';
|
||
const showSetMax = part.minimized ? 'hidden' : '';
|
||
const partNoteId = `part-note-${project.id}-${part.id}`;
|
||
const countId = `count-${part.id}`;
|
||
const pulseClass = lastCountPulse && lastCountPulse.partId === part.id
|
||
? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down')
|
||
: '';
|
||
const finishPulseClass = part.finished && lastFinishedId === part.id ? 'finish-shimmer' : '';
|
||
const partCardId = `part-${part.id}`;
|
||
const partCardFullClass = `${isLocked} ${isFinished} ${isMinimized} ${finishPulseClass}`;
|
||
const lockDisabled = part.locked ? 'disabled' : '';
|
||
const actionsHtml = part.minimized
|
||
? `<div class="part-actions"><button class="icon-btn btn-toggle-part" onclick="togglePartMinimize('${project.id}', '${part.id}')" title="Expand"><i class="fa-solid fa-chevron-down"></i></button></div>`
|
||
: `<div class="part-actions">
|
||
<button class="btn-color" style="--project-color: ${accent}" onclick="openColorPicker('${project.id}', '${part.id}')" title="Set color"></button>
|
||
<button class="icon-btn btn-reset-part" onclick="resetCount('${project.id}', '${part.id}')" ${isFinished || part.locked ? 'disabled' : ''}><i class="fa-solid fa-rotate-left"></i></button>
|
||
<button class="icon-btn btn-delete-part" onclick="deletePart('${project.id}', '${part.id}')" ${lockDisabled}><i class="fa-solid fa-trash"></i></button>
|
||
<button class="icon-btn btn-toggle-part" onclick="togglePartMinimize('${project.id}', '${part.id}')" title="Minimize"><i class="fa-solid fa-chevron-down"></i></button>
|
||
</div>`;
|
||
const countSubtext = part.minimized ? '' : `
|
||
<div class="count-subtext">
|
||
${part.max !== null ? `<strong>${part.count}</strong> / ${part.max}` : 'No max set'}
|
||
<button class="icon-btn ${showSetMax}" onclick="openModal('setMax', '${project.id}', '${part.id}')" title="Set max" ${lockDisabled}><i class="fa-solid fa-gear"></i></button>
|
||
</div>
|
||
`;
|
||
|
||
partsHtml += `
|
||
<div class="part-card ${partCardFullClass}" id="${partCardId}" style="--project-color: ${accent}">
|
||
<div class="part-header">
|
||
<div class="part-name-group">
|
||
<label class="check-container">
|
||
<input type="checkbox" ${part.finished ? 'checked' : ''} onchange="togglePartFinish('${project.id}', '${part.id}')">
|
||
<span class="checkmark"></span>
|
||
</label>
|
||
<span class="part-name" onclick="openModal('renamePart', '${project.id}', '${part.id}')">${part.name}</span>
|
||
<span class="part-mini-count">${part.count}</span>
|
||
</div>
|
||
${actionsHtml}
|
||
</div>
|
||
<div class="count-display ${pulseClass}" id="${countId}" ondblclick="openModal('manualCount', '${project.id}', '${part.id}')">${part.count}</div>
|
||
${countSubtext}
|
||
<div class="controls ${hideControls}">
|
||
<button class="action-btn btn-minus ${controlsDimmed}" onclick="updateCount('${project.id}', '${part.id}', -1)">-</button>
|
||
<button class="action-btn ${lockBtnClass}" onclick="togglePartLock('${project.id}', '${part.id}')">${lockIcon}</button>
|
||
<button class="action-btn btn-plus ${controlsDimmed}" onclick="updateCount('${project.id}', '${part.id}', 1)">+</button>
|
||
</div>
|
||
${part.instructions ? `
|
||
<div class="instructions-block ${part.instructionsCollapsed ? 'collapsed' : ''}">
|
||
<div class="instructions-head">
|
||
<span>Pattern</span>
|
||
<button class="note-toggle" onclick="togglePartInstructions('${project.id}', '${part.id}')">${part.instructionsCollapsed ? 'Show' : 'Hide'}</button>
|
||
</div>
|
||
<div class="instructions-body">
|
||
<div class="instruction-title">${part.instructions.title || ''}</div>
|
||
<ul class="instruction-rows">
|
||
${(part.instructions.rows || []).map((r,i)=>`<li>Row ${i+1}: ${r}</li>`).join('') || '<li class="muted">No rows</li>'}
|
||
</ul>
|
||
${part.instructions.note ? `<div class="instruction-note">${part.instructions.note}</div>` : ''}
|
||
${part.instructions.image ? `<img class="instruction-image" src="${resolveImageUrl(part.instructions.image)}" alt="Pattern step image">` : ''}
|
||
</div>
|
||
</div>` : ''}
|
||
<div class="note-area" id="${partNoteId}">
|
||
<textarea placeholder="Notes for this part..." oninput="updatePartNote(event, '${project.id}', '${part.id}')">${part.note || ''}</textarea>
|
||
</div>
|
||
<button class="note-toggle" onclick="toggleNote('${partNoteId}')">Notes</button>
|
||
</div>`;
|
||
});
|
||
|
||
const projectContainer = document.createElement('div');
|
||
projectContainer.className = `project-container ${projectCollapsedClass}`;
|
||
projectContainer.style = `--project-color: ${project.color}`;
|
||
const projectNoteId = `project-note-${project.id}`;
|
||
projectContainer.innerHTML = `
|
||
<div class="project-header">
|
||
<div class="project-title-group">
|
||
<button class="btn-toggle-project" onclick="toggleProjectCollapse('${project.id}')">▼</button>
|
||
<span class="project-title">${project.name}</span>
|
||
<button class="btn-rename-project" onclick="renameProject('${project.id}')" title="Rename project"><span class="icon-pencil">✎</span></button>
|
||
</div>
|
||
<div class="project-actions">
|
||
<button class="btn-add-part" onclick="openModal('addPart', '${project.id}')">+ Part</button>
|
||
${project.patternId ? `
|
||
<span class="project-link-icon" title="Linked to pattern">
|
||
<i class="fa-solid fa-link"></i>
|
||
</span>
|
||
` : `
|
||
<button class="btn-save-pattern" onclick="saveProjectAsPattern('${project.id}')" title="Save as pattern"><i class="fa-solid fa-swatchbook"></i></button>
|
||
`}
|
||
<button class="btn-delete-project" onclick="deleteProject('${project.id}')">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="note-area" id="${projectNoteId}">
|
||
<textarea placeholder="Notes for this project..." oninput="updateProjectNote(event, '${project.id}')">${project.note || ''}</textarea>
|
||
</div>
|
||
<button class="note-toggle" onclick="toggleNote('${projectNoteId}')">Notes</button>
|
||
<div class="part-list">${partsHtml}</div>
|
||
`;
|
||
grid.appendChild(projectContainer);
|
||
});
|
||
|
||
lastCountPulse = null;
|
||
lastFinishedId = null;
|
||
app.appendChild(grid);
|
||
}
|
||
|
||
render();
|
||
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.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 => `
|
||
<div class="admin-item">
|
||
<div class="meta">
|
||
<strong>${u.email}</strong>
|
||
<span>${u.display_name || ''}</span>
|
||
</div>
|
||
<div class="admin-actions">
|
||
<button class="modal-btn btn-save" onclick="approveUser('${u.id}')">Approve</button>
|
||
<button class="modal-btn btn-cancel" onclick="suspendUser('${u.id}')">Suspend</button>
|
||
<button class="modal-btn btn-save" onclick="makeAdmin('${u.id}')">Make admin</button>
|
||
</div>
|
||
</div>
|
||
`).join('') || '<p class="auth-hint">No pending users.</p>';
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
}
|
||
}
|