|
| 1 | +const METADATA_URL = '/samples/metadata/samples.index.metadata.json'; |
| 2 | +const BACK_TO_SAMPLES_HREF = '/samples/index.html'; |
| 3 | +const ENHANCER_STYLE_ID = 'sample-detail-enhancement-style'; |
| 4 | +const ENHANCER_ROOT_ID = 'sample-detail-enhancement-root'; |
| 5 | + |
| 6 | +export function parseSampleFromPathname(pathname) { |
| 7 | + const text = String(pathname || ''); |
| 8 | + const direct = text.match(/\/samples\/phase(\d{2})\/(\d{4})(?:\/index\.html)?\/?$/); |
| 9 | + if (direct) { |
| 10 | + return { phase: direct[1], id: direct[2] }; |
| 11 | + } |
| 12 | + |
| 13 | + const parts = text.split('/').filter(Boolean); |
| 14 | + for (let i = 0; i < parts.length - 1; i += 1) { |
| 15 | + if (/^phase\d{2}$/.test(parts[i]) && /^\d{4}$/.test(parts[i + 1])) { |
| 16 | + return { phase: parts[i].slice(5), id: parts[i + 1] }; |
| 17 | + } |
| 18 | + } |
| 19 | + return null; |
| 20 | +} |
| 21 | + |
| 22 | +export function canonicalSampleHref(sample) { |
| 23 | + return '/samples/phase' + sample.phase + '/' + sample.id + '/index.html'; |
| 24 | +} |
| 25 | + |
| 26 | +export function normalizeMetadata(raw) { |
| 27 | + if (!raw || typeof raw !== 'object') { |
| 28 | + throw new Error('Metadata root must be an object.'); |
| 29 | + } |
| 30 | + const samples = Array.isArray(raw.samples) ? raw.samples : []; |
| 31 | + |
| 32 | + const normalized = samples.map((entry) => ({ |
| 33 | + id: String(entry.id || '').trim(), |
| 34 | + phase: String(entry.phase || '').trim(), |
| 35 | + title: String(entry.title || '').trim(), |
| 36 | + description: String(entry.description || '').trim(), |
| 37 | + tags: Array.isArray(entry.tags) ? entry.tags.map((tag) => String(tag).trim()).filter(Boolean) : [], |
| 38 | + preview: entry && typeof entry.preview === 'string' ? entry.preview.trim() : '' |
| 39 | + })); |
| 40 | + |
| 41 | + const valid = normalized.filter((entry) => /^\d{4}$/.test(entry.id) && /^\d{2}$/.test(entry.phase)); |
| 42 | + valid.sort((a, b) => a.id.localeCompare(b.id)); |
| 43 | + return valid; |
| 44 | +} |
| 45 | + |
| 46 | +export function buildDetailModel(samples, currentId) { |
| 47 | + const list = Array.isArray(samples) ? samples : []; |
| 48 | + const index = list.findIndex((entry) => entry.id === currentId); |
| 49 | + if (index < 0) { |
| 50 | + return null; |
| 51 | + } |
| 52 | + |
| 53 | + const current = list[index]; |
| 54 | + const previous = index > 0 ? list[index - 1] : null; |
| 55 | + const next = index < list.length - 1 ? list[index + 1] : null; |
| 56 | + |
| 57 | + const samePhase = list.filter((entry) => entry.phase === current.phase && entry.id !== current.id); |
| 58 | + const related = samePhase.slice(0, 3); |
| 59 | + |
| 60 | + return { current, previous, next, related }; |
| 61 | +} |
| 62 | + |
| 63 | +function ensureStyles() { |
| 64 | + if (document.getElementById(ENHANCER_STYLE_ID)) { |
| 65 | + return; |
| 66 | + } |
| 67 | + |
| 68 | + const style = document.createElement('style'); |
| 69 | + style.id = ENHANCER_STYLE_ID; |
| 70 | + style.textContent = [ |
| 71 | + '.sample-detail-enhancement {', |
| 72 | + ' margin: 0 0 14px;', |
| 73 | + ' padding: 12px;', |
| 74 | + ' border: 1px solid #2d3a52;', |
| 75 | + ' border-radius: 8px;', |
| 76 | + ' background: #111826;', |
| 77 | + ' color: #e7edf8;', |
| 78 | + '}', |
| 79 | + '.sample-detail-enhancement h2 { margin: 0 0 6px; font-size: 1.1rem; }', |
| 80 | + '.sample-detail-enhancement p { margin: 0 0 8px; color: #c7d5ea; }', |
| 81 | + '.sample-detail-enhancement .meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }', |
| 82 | + '.sample-detail-enhancement .chip {', |
| 83 | + ' display: inline-block;', |
| 84 | + ' padding: 3px 8px;', |
| 85 | + ' border-radius: 999px;', |
| 86 | + ' border: 1px solid #415172;', |
| 87 | + ' background: #1a2538;', |
| 88 | + ' font: 600 12px/1.2 monospace;', |
| 89 | + '}', |
| 90 | + '.sample-detail-enhancement nav { display: flex; flex-wrap: wrap; gap: 12px; margin: 10px 0 2px; }', |
| 91 | + '.sample-detail-enhancement a { color: #9bd1ff; }', |
| 92 | + '.sample-detail-enhancement .related { margin: 10px 0 0; }', |
| 93 | + '.sample-detail-enhancement .related ul { margin: 6px 0 0 18px; padding: 0; }', |
| 94 | + '.sample-detail-enhancement .related li { margin: 2px 0; }', |
| 95 | + '.sample-detail-enhancement figure { margin: 10px 0 0; }', |
| 96 | + '.sample-detail-enhancement img { max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #374765; }' |
| 97 | + ].join('\n'); |
| 98 | + |
| 99 | + document.head.appendChild(style); |
| 100 | +} |
| 101 | + |
| 102 | +function createLink(href, text) { |
| 103 | + const link = document.createElement('a'); |
| 104 | + link.href = href; |
| 105 | + link.textContent = text; |
| 106 | + return link; |
| 107 | +} |
| 108 | + |
| 109 | +function buildEnhancementElement(model) { |
| 110 | + const root = document.createElement('section'); |
| 111 | + root.className = 'sample-detail-enhancement'; |
| 112 | + root.id = ENHANCER_ROOT_ID; |
| 113 | + |
| 114 | + const title = document.createElement('h2'); |
| 115 | + title.textContent = 'Sample ' + model.current.id + ' - ' + model.current.title; |
| 116 | + root.appendChild(title); |
| 117 | + |
| 118 | + const description = document.createElement('p'); |
| 119 | + description.textContent = model.current.description || 'No description available.'; |
| 120 | + root.appendChild(description); |
| 121 | + |
| 122 | + const metaRow = document.createElement('div'); |
| 123 | + metaRow.className = 'meta-row'; |
| 124 | + |
| 125 | + const phaseChip = document.createElement('span'); |
| 126 | + phaseChip.className = 'chip'; |
| 127 | + phaseChip.textContent = 'Phase ' + model.current.phase; |
| 128 | + metaRow.appendChild(phaseChip); |
| 129 | + |
| 130 | + const tags = model.current.tags.length > 0 ? model.current.tags : ['Untagged']; |
| 131 | + for (const tag of tags) { |
| 132 | + const tagChip = document.createElement('span'); |
| 133 | + tagChip.className = 'chip'; |
| 134 | + tagChip.textContent = tag; |
| 135 | + metaRow.appendChild(tagChip); |
| 136 | + } |
| 137 | + |
| 138 | + root.appendChild(metaRow); |
| 139 | + |
| 140 | + const nav = document.createElement('nav'); |
| 141 | + nav.appendChild(createLink(BACK_TO_SAMPLES_HREF, 'Back to Samples')); |
| 142 | + |
| 143 | + if (model.previous) { |
| 144 | + nav.appendChild(createLink(canonicalSampleHref(model.previous), 'Previous: ' + model.previous.id)); |
| 145 | + } |
| 146 | + if (model.next) { |
| 147 | + nav.appendChild(createLink(canonicalSampleHref(model.next), 'Next: ' + model.next.id)); |
| 148 | + } |
| 149 | + |
| 150 | + root.appendChild(nav); |
| 151 | + |
| 152 | + if (model.current.preview) { |
| 153 | + const figure = document.createElement('figure'); |
| 154 | + const image = document.createElement('img'); |
| 155 | + image.src = model.current.preview; |
| 156 | + image.alt = 'Preview for sample ' + model.current.id; |
| 157 | + figure.appendChild(image); |
| 158 | + root.appendChild(figure); |
| 159 | + } |
| 160 | + |
| 161 | + const relatedWrap = document.createElement('div'); |
| 162 | + relatedWrap.className = 'related'; |
| 163 | + const relatedHeading = document.createElement('strong'); |
| 164 | + relatedHeading.textContent = 'Related samples'; |
| 165 | + relatedWrap.appendChild(relatedHeading); |
| 166 | + |
| 167 | + if (model.related.length === 0) { |
| 168 | + const none = document.createElement('p'); |
| 169 | + none.textContent = 'No related samples available.'; |
| 170 | + relatedWrap.appendChild(none); |
| 171 | + } else { |
| 172 | + const list = document.createElement('ul'); |
| 173 | + for (const sample of model.related) { |
| 174 | + const item = document.createElement('li'); |
| 175 | + item.appendChild(createLink(canonicalSampleHref(sample), 'Sample ' + sample.id + ' - ' + sample.title)); |
| 176 | + list.appendChild(item); |
| 177 | + } |
| 178 | + relatedWrap.appendChild(list); |
| 179 | + } |
| 180 | + |
| 181 | + root.appendChild(relatedWrap); |
| 182 | + return root; |
| 183 | +} |
| 184 | + |
| 185 | +async function loadMetadata() { |
| 186 | + const response = await fetch(METADATA_URL, { cache: 'no-store' }); |
| 187 | + if (!response.ok) { |
| 188 | + throw new Error('Metadata request failed: ' + response.status); |
| 189 | + } |
| 190 | + return response.json(); |
| 191 | +} |
| 192 | + |
| 193 | +export async function applySampleDetailEnhancement() { |
| 194 | + const current = parseSampleFromPathname(window.location.pathname); |
| 195 | + if (!current) { |
| 196 | + return; |
| 197 | + } |
| 198 | + |
| 199 | + const mount = document.querySelector('main'); |
| 200 | + if (!mount || document.getElementById(ENHANCER_ROOT_ID)) { |
| 201 | + return; |
| 202 | + } |
| 203 | + |
| 204 | + const raw = await loadMetadata(); |
| 205 | + const samples = normalizeMetadata(raw); |
| 206 | + const model = buildDetailModel(samples, current.id); |
| 207 | + if (!model) { |
| 208 | + return; |
| 209 | + } |
| 210 | + |
| 211 | + ensureStyles(); |
| 212 | + const element = buildEnhancementElement(model); |
| 213 | + mount.insertBefore(element, mount.firstChild); |
| 214 | + |
| 215 | + const titleText = 'Sample ' + model.current.id + ' - ' + model.current.title; |
| 216 | + if (!document.title || !document.title.startsWith('Sample ' + model.current.id)) { |
| 217 | + document.title = titleText; |
| 218 | + } |
| 219 | +} |
| 220 | + |
| 221 | +if (typeof window !== 'undefined' && typeof document !== 'undefined') { |
| 222 | + applySampleDetailEnhancement().catch((error) => { |
| 223 | + console.warn('[sample-detail-enhancement]', error && error.message ? error.message : error); |
| 224 | + }); |
| 225 | +} |
0 commit comments