4075 lines
164 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// --- Data Init & Colors ---
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || [];
if (!Array.isArray(patterns)) patterns = [];
let 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">&times;</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; // 5vh60vh
const scale = 0.9 + Math.random() * 0.4;
const duration = 12 + Math.random() * 8; // 1220s
const chosenSide = side === 'any' ? ['left','right','top','bottom'][Math.floor(Math.random()*4)] : side;
let startX = '-10vw', endX = '110vw', startY = `${top}vh`, endY = `${top + (Math.random()*12 - 6)}vh`;
let midX = '25vw', midY = `${top - 6}vh`, mid2X = '65vw', mid2Y = `${top + 6}vh`;
if (chosenSide === 'right') {
startX = '110vw'; endX = '-10vw';
midX = '-25vw'; mid2X = '-65vw';
} else if (chosenSide === 'top') {
const x = Math.random()*80 + 10;
startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`;
startY = '-12vh'; endY = '110vh';
midX = `${x + 8}vw`; mid2X = `${x - 8}vw`;
midY = '25vh'; mid2Y = '65vh';
} else if (chosenSide === 'bottom') {
const x = Math.random()*80 + 10;
startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`;
startY = '110vh'; endY = '-12vh';
midX = `${x - 8}vw`; mid2X = `${x + 8}vw`;
midY = '75vh'; mid2Y = '35vh';
}
wrap.style.setProperty('--fly-scale', scale);
wrap.style.setProperty('--fly-duration', `${duration}s`);
wrap.style.setProperty('--fly-start-x', startX);
wrap.style.setProperty('--fly-start-y', startY);
wrap.style.setProperty('--fly-mid-x', midX);
wrap.style.setProperty('--fly-mid-y', midY);
wrap.style.setProperty('--fly-mid2-x', mid2X);
wrap.style.setProperty('--fly-mid2-y', mid2Y);
wrap.style.setProperty('--fly-end-x', endX);
wrap.style.setProperty('--fly-end-y', endY);
if (markActive) fireflyActive = true;
wrap.addEventListener('animationend', (e) => {
if (e.animationName !== 'fireflyGlide') return;
wrap.remove();
if (markActive) fireflyActive = false;
});
wrap.appendChild(el);
document.body.appendChild(wrap);
}
function spawnSeed({ markActive = false, source = 'ambient' } = {}) {
const wrap = document.createElement('div');
wrap.className = 'seed-wrap';
const el = document.createElement('div');
el.className = 'seed';
const top = Math.random() * 55 + 5; // 5vh60vh
const scale = 0.85 + Math.random() * 0.4;
const duration = 14 + Math.random() * 8; // 1422s
const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-824deg
const sway = 4 + Math.random() * 6; // px
const flipDur = 5 + Math.random() * 4; // 59s
const dir = ['left','right','top'][Math.floor(Math.random()*3)];
const fromLeft = dir === 'left';
let start = fromLeft ? '-12vw' : '112vw';
let mid = fromLeft ? '30vw' : '-30vw';
let end = fromLeft ? '112vw' : '-12vw';
if (dir === 'top') {
const x = Math.random()*80 + 10;
start = `${x}vw`;
mid = `${x + (Math.random()*10 - 5)}vw`;
end = `${x + (Math.random()*20 - 10)}vw`;
wrap.style.top = '-12vh';
} else {
wrap.style.top = `${top}vh`;
}
wrap.style.setProperty('--seed-scale', scale);
wrap.style.setProperty('--seed-duration', `${duration}s`);
wrap.style.setProperty('--seed-tilt', `${tilt}deg`);
wrap.style.setProperty('--seed-sway', `${sway}px`);
wrap.style.setProperty('--seed-flip-duration', `${flipDur}s`);
wrap.style.setProperty('--seed-start', start);
wrap.style.setProperty('--seed-mid', mid);
wrap.style.setProperty('--seed-end', end);
if (markActive) fireflyActive = true;
wrap.addEventListener('animationend', (e) => {
if (e.animationName !== 'seedGlide') return;
wrap.remove();
if (markActive) fireflyActive = false;
});
wrap.appendChild(el);
document.body.appendChild(wrap);
}
function stopAmbientDrift() {
if (fireflyTimer) {
clearTimeout(fireflyTimer);
fireflyTimer = null;
}
document.querySelectorAll('.firefly-wrap').forEach(el => el.remove());
document.querySelectorAll('.seed-wrap').forEach(el => el.remove());
fireflyActive = false;
}
function scheduleAmbientDrift() {
const delay = 10000 + Math.random() * 10000; // 1020s
fireflyTimer = setTimeout(() => {
if (!animationsEnabled) { stopAmbientDrift(); return; }
const selector = isDarkMode ? '.firefly-wrap' : '.seed-wrap';
let existing = document.querySelectorAll(selector).length;
if (existing === 0) {
isDarkMode ? spawnFirefly() : spawnSeed();
existing++;
} else if (existing < 5) {
isDarkMode ? spawnFirefly() : spawnSeed();
}
scheduleAmbientDrift();
}, delay);
}
function handleAmbientDrift() {
stopAmbientDrift();
if (!animationsEnabled) return;
if (isDarkMode) {
spawnFirefly();
} else {
spawnSeed();
}
scheduleAmbientDrift();
}
handleAmbientDrift();
const logoIcon = document.querySelector('.brand-icon');
if (logoIcon) {
logoIcon.addEventListener('click', () => {
if (!animationsEnabled || fireflyActive) return;
if (isDarkMode) {
spawnFirefly({ markActive: true, source: 'logo', side: 'any' });
} else {
spawnSeed({ markActive: true, source: 'logo' });
}
});
}
if (colorOverlay) {
colorOverlay.addEventListener('click', (e) => {
if (e.target === colorOverlay) closeColorPicker();
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && colorOverlay && colorOverlay.classList.contains('active')) {
closeColorPicker();
}
});
const importBtn = document.getElementById('importBtn');
if (importBtn) {
importBtn.addEventListener('click', triggerImport);
}
const titleEl = document.getElementById('appTitle');
if (titleEl) {
titleEl.addEventListener('click', () => {
const now = Date.now();
titleClicks = titleClicks.filter(ts => now - ts < 7000);
titleClicks.push(now);
if (titleClicks.length >= 5 && !easterEggCooling) {
easterEggCooling = true;
triggerBurst();
setTimeout(() => {
easterEggCooling = false;
titleClicks = [];
}, 8000);
}
});
}
function triggerBurst() {
if (!animationsEnabled) return;
const burstCount = isDarkMode ? 24 : 18;
const spawner = isDarkMode ? (opts) => spawnFirefly({ ...opts, side: 'any' }) : spawnSeed;
for (let i = 0; i < burstCount; i++) {
const jitter = Math.random() * 200;
setTimeout(() => spawner({ source: 'burst' }), i * 140 + jitter);
}
}
// --- Focus Mode Logic ---
let wakeLock = null;
let isFocusMode = false;
const focusBtn = document.getElementById('focusBtn');
// --- Migration Check ---
if (projects.length > 0) {
let changed = false;
projects.forEach((p, index) => {
if (!p.parts) { p.parts = []; changed = true; }
if (!p.color) { p.color = colors[index % colors.length]; changed = true; }
const oldIdx = oldColors.indexOf(p.color);
if (oldIdx !== -1) { p.color = colors[oldIdx % colors.length]; changed = true; }
if (p.note === undefined) { p.note = ''; changed = true; }
p.parts.forEach(pt => {
if (pt.max === undefined) { pt.max = null; changed = true; }
if (pt.note === undefined) { pt.note = ''; changed = true; }
if (!pt.color) { pt.color = p.color; changed = true; }
const oldPartIdx = oldColors.indexOf(pt.color);
if (oldPartIdx !== -1) { pt.color = colors[oldPartIdx % colors.length]; changed = true; }
});
});
if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); }
}
// --- Core Functions ---
function save() {
localStorage.setItem('crochetCounters', JSON.stringify(projects));
render();
}
async function toggleFocusMode() {
if (!isFocusMode) {
try {
if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen();
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
}
isFocusMode = true;
focusBtn.classList.add('is-active');
} catch (err) { showAlert({ title: 'Focus Mode failed', text: err.message }); }
} else {
if (document.fullscreenElement) document.exitFullscreen();
if (wakeLock !== null) { wakeLock.release(); wakeLock = null; }
isFocusMode = false;
focusBtn.classList.remove('is-active');
}
}
document.addEventListener('visibilitychange', async () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
wakeLock = await navigator.wakeLock.request('screen');
}
});
// --- Interaction Logic ---
async function deleteProject(pId) {
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 = '';
}
}