toadstoolTally/pattern-viewer.html

308 lines
14 KiB
HTML
Raw Permalink 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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pattern Viewer</title>
<link rel="stylesheet" href="assets/style.css">
<style>
body { background: var(--bg); color: var(--text); padding: 16px; }
.viewer { max-width: 720px; margin: 0 auto; padding: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 10px 24px rgba(0,0,0,0.1); }
.viewer h1 { margin: 0 0 6px; color: var(--project-color); }
.viewer h3 { margin: 12px 0 6px; }
.viewer pre { white-space: pre-wrap; background: var(--input-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); }
.step-list { display: grid; gap: 12px; margin: 12px 0; }
.step-card { border: 1px solid var(--border); border-radius: 12px; padding: 14px; background: var(--card-bg); box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.step-card.collapsed .step-body { display: none; }
.step-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; cursor: pointer; }
.step-title .title { font-weight: 700; }
.step-title .caret { color: var(--text-muted); transition: transform 0.2s ease; }
.step-card.collapsed .step-title .caret { transform: rotate(-90deg); }
.step-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.step-title input { margin-right: 8px; }
.step-rows { margin-top: 8px; display: grid; gap: 6px; }
.step-row { cursor: pointer; }
.step-row.is-done { text-decoration: line-through; color: var(--text-muted); }
.step-title-bar {
font-weight: 700;
color: var(--card-bg);
background: var(--project-color);
padding: 6px 10px;
border-radius: 8px;
margin-bottom: 8px;
display: inline-block;
}
.step-media { margin-top: 8px; }
.step-media img { max-width: 260px; max-height: 260px; width: auto; height: auto; border-radius: 10px; border: 1px solid var(--border); display: block; cursor: zoom-in; }
.note-box { width: 100%; min-height: 80px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 8px; }
.meta { display: grid; gap: 6px; margin: 10px 0; font-size: 0.95rem; }
.pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: var(--input-bg); border: 1px solid var(--border); font-size: 0.85rem; margin-right: 6px; margin-bottom: 4px; }
.top-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.back-link { text-decoration: none; color: var(--text); font-weight: 700; }
.back-link:hover { color: var(--project-color); }
.list { margin: 0; padding-left: 18px; }
.list li { margin-bottom: 4px; }
.color-swatch { display: inline-flex; align-items: center; gap: 6px; }
.swatch-dot { width: 12px; height: 12px; border-radius: 50%; border: 1px solid var(--border); display: inline-block; }
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(44, 35, 25, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 400;
padding: 16px;
}
.image-preview-overlay.active { display: flex; }
.image-preview-dialog {
background: var(--card-bg);
border-radius: 16px;
border: 1px solid var(--border);
padding: 12px;
max-width: min(920px, 92vw);
max-height: 90vh;
position: relative;
}
.image-preview-img {
max-width: 100%;
max-height: calc(90vh - 40px);
display: block;
border-radius: 12px;
}
.image-preview-close {
position: absolute;
top: 6px;
right: 8px;
border: none;
background: transparent;
font-size: 1.6rem;
color: var(--text-muted);
cursor: pointer;
}
.image-preview-close:hover { color: var(--text); }
.viewer-photo-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; }
.viewer-photo { max-width: 100%; border-radius: 10px; border: 1px solid var(--border); cursor: zoom-in; }
@media (max-width: 600px) { .viewer { padding: 12px; } }
</style>
</head>
<body>
<div class="viewer">
<div class="top-actions">
<a class="back-link" href="./">← Back to app</a>
<span id="viewerStatus" class="pill">Progress saved locally</span>
</div>
<h1 id="pvTitle">Pattern</h1>
<div id="pvDesigner" class="meta"></div>
<div id="pvGauge" class="meta"></div>
<div id="pvMaterials"></div>
<div id="pvTools"></div>
<div id="pvPalette"></div>
<div id="pvFinishedPhotos"></div>
<div id="pvAbbrev"></div>
<div id="pvStitches"></div>
<div id="pvRows"></div>
<h3>Steps</h3>
<div id="pvSteps" class="step-list"></div>
<h3>Notes</h3>
<textarea id="pvNotes" class="note-box" placeholder="Your notes..." aria-label="Notes"></textarea>
</div>
<script>
const params = new URLSearchParams(location.search);
const data = params.get('data');
const token = params.get('token');
let patternDraft = null;
function renderError(message) {
document.body.innerHTML = `<div style="padding:20px;">Failed to load pattern: ${message}</div>`;
}
function resolveImageUrl(url) {
if (!url) return '';
if (/^https?:\/\//i.test(url) || url.startsWith('data:')) return url;
if (url.startsWith('/uploads/')) {
return `${window.location.origin}${url}`;
}
return url;
}
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');
}
async function loadPattern() {
try {
if (data) {
const json = decodeURIComponent(escape(atob(data)));
const parsed = JSON.parse(json);
if (!parsed.patternDraft) throw new Error('Invalid payload');
return parsed.patternDraft;
}
if (token) {
const resp = await fetch(`/share/${encodeURIComponent(token)}`);
const payload = await resp.json();
if (!resp.ok) throw new Error(payload.error || 'Share link invalid');
const serverData = payload.pattern?.data?.draft || payload.pattern?.data;
if (!serverData) throw new Error('Missing pattern data');
return serverData;
}
throw new Error('Missing pattern data');
} catch (e) {
renderError(e.message);
throw e;
}
}
let progress = {};
let storageKey = '';
function saveProgress() {
localStorage.setItem(storageKey, JSON.stringify(progress));
}
const weightLabels = new Map([
['0', 'Lace (0)'],
['1', 'Super Fine (1)'],
['2', 'Fine (2)'],
['3', 'Light (3)'],
['4', 'Medium (4)'],
['5', 'Bulky (5)'],
['6', 'Super Bulky (6)'],
['7', 'Jumbo (7)']
]);
function render() {
document.getElementById('pvTitle').textContent = patternDraft.meta?.title || 'Pattern';
document.getElementById('pvDesigner').textContent = patternDraft.meta?.designer || '';
const gaugeBlock = [
patternDraft.gaugeSts || '',
patternDraft.gaugeRows ? ' - ' + patternDraft.gaugeRows : '',
patternDraft.gaugeHook ? ' - ' + patternDraft.gaugeHook : ''
].filter(Boolean).join('');
document.getElementById('pvGauge').innerHTML = gaugeBlock || patternDraft.size
? `<div><strong>Gauge / Size:</strong> ${[gaugeBlock, patternDraft.gauge || '', patternDraft.size || ''].filter(Boolean).join(' | ')}</div>`
: '';
const mats = patternDraft.materials || '';
document.getElementById('pvMaterials').innerHTML = mats ? `<h3>Materials</h3><pre>${mats}</pre>` : '';
const yarns = (patternDraft.yarns || []).map(y => {
const weightKey = y.weight !== undefined && y.weight !== null ? String(y.weight) : '';
const weightLabel = weightLabels.get(weightKey) || (weightKey ? `Weight ${weightKey}` : '');
const label = `${y.note || 'Yarn'}${weightLabel ? `: ${weightLabel}` : ''}`.trim();
return `<li>${label}${y.color ? ` <span class="color-swatch"><span class="swatch-dot" style="background:${y.color}"></span></span>` : ''}</li>`;
}).join('');
const hooks = (patternDraft.hooks || []).map(h => `<li>${h.size || ''}${h.note ? ` (${h.note})` : ''}</li>`).join('');
document.getElementById('pvTools').innerHTML = (yarns || hooks)
? `<h3>Yarns & Hooks</h3>${yarns ? `<ul class="list">${yarns}</ul>` : ''}${hooks ? `<p><strong>Hooks:</strong></p><ul class="list">${hooks}</ul>` : ''}`
: '';
const palette = (patternDraft.palette || []).map(c => `<li class="color-swatch"><span class="swatch-dot" style="background:${c}"></span></li>`).join('');
document.getElementById('pvPalette').innerHTML = palette ? `<h3>Palette</h3><ul class="list">${palette}</ul>` : '';
const finishedPhotos = (patternDraft.finishedPhotos || []).map(p => {
const url = resolveImageUrl(p.url);
return `<img class="viewer-photo" src="${url}" alt="Finished photo" onclick="openImagePreview('${url}')">`;
}).join('');
document.getElementById('pvFinishedPhotos').innerHTML = finishedPhotos ? `<h3>Finished Photos</h3><div class="viewer-photo-grid">${finishedPhotos}</div>` : '';
const customMap = new Map((patternDraft.customAbbrev || []).map(item => [item.code, item.desc]));
const selected = (patternDraft.abbrevSelection || []).map(code => {
const desc = customMap.get(code) || '';
return desc ? `<li>${code} - ${desc}</li>` : `<li>${code}</li>`;
}).join('');
const abbrevText = (patternDraft.abbrev || '').trim();
document.getElementById('pvAbbrev').innerHTML = abbrevText || selected
? `<h3>Abbreviations</h3>${abbrevText ? `<pre>${abbrevText}</pre>` : `<ul class="list">${selected}</ul>`}`
: '';
const stitches = (patternDraft.stitches || '').trim();
document.getElementById('pvStitches').innerHTML = stitches ? `<h3>Stitch Guide</h3><pre>${stitches}</pre>` : '';
const rowsOutput = (patternDraft.output || '').trim();
document.getElementById('pvRows').innerHTML = rowsOutput ? `<h3>Rows</h3><pre>${rowsOutput}</pre>` : '';
const stepsEl = document.getElementById('pvSteps');
stepsEl.innerHTML = '';
(patternDraft.steps || []).forEach((step, idx) => {
const card = document.createElement('div');
const isCollapsed = progress[idx]?.collapsed;
card.className = `step-card ${isCollapsed ? 'collapsed' : ''}`;
const rows = (step.rows || []).map((r,i) => {
const done = progress[idx]?.rows?.[i];
return `<div class="step-row ${done ? 'is-done' : ''}" data-step="${idx}" data-row="${i}">Row ${i+1}: ${r}</div>`;
}).join('');
const note = step.note ? `<p><strong>Step Note:</strong> ${step.note}</p>` : '';
const imgUrl = resolveImageUrl(step.image);
const img = step.image ? `<div class="step-media"><img src="${imgUrl}" alt="Step ${idx + 1} image" onclick="openImagePreview('${imgUrl}')"></div>` : '';
card.innerHTML = `
<div class="step-title" data-step="${idx}">
<div class="title">Step ${idx+1}${step.title ? ` ${step.title}` : ''}</div>
<div class="caret">▾</div>
</div>
<div class="step-body">
<div class="step-title-bar">${step.title || `Step ${idx + 1}`}</div>
<div class="step-rows">${rows || '<em>No rows yet.</em>'}</div>
${note}
${img}
<div style="margin-top:6px;">
<label>Notes</label>
<textarea data-step="${idx}" class="note-box" placeholder="Notes for this step...">${progress[idx]?.note || ''}</textarea>
</div>
</div>
`;
stepsEl.appendChild(card);
});
const pvNotes = document.getElementById('pvNotes');
pvNotes.value = progress.globalNote || '';
}
document.addEventListener('click', (e) => {
if (e.target.matches('.step-title')) {
const s = Number(e.target.dataset.step);
progress[s] = progress[s] || { rows: {} };
progress[s].collapsed = !progress[s].collapsed;
saveProgress();
const card = e.target.closest('.step-card');
if (card) card.classList.toggle('collapsed', progress[s].collapsed);
}
if (e.target.matches('.step-row')) {
const s = Number(e.target.dataset.step);
const r = Number(e.target.dataset.row);
progress[s] = progress[s] || { rows: {} };
progress[s].rows = progress[s].rows || {};
progress[s].rows[r] = !progress[s].rows[r];
saveProgress();
e.target.classList.toggle('is-done', progress[s].rows[r]);
}
});
document.addEventListener('input', (e) => {
if (e.target.matches('textarea[data-step]')) {
const s = Number(e.target.dataset.step);
progress[s] = progress[s] || { rows: {} };
progress[s].note = e.target.value;
saveProgress();
}
if (e.target.id === 'pvNotes') {
progress.globalNote = e.target.value;
saveProgress();
}
});
(async function init() {
patternDraft = await loadPattern();
storageKey = `pattern-viewer-${patternDraft.meta?.title || 'pattern'}`;
progress = JSON.parse(localStorage.getItem(storageKey) || '{}');
render();
})();
</script>
</body>
</html>