308 lines
14 KiB
HTML
308 lines
14 KiB
HTML
<!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">×</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>
|