loreal-spec-tool/script.js
Phil Dore 21fcf63431 Initial commit — L'Oréal Spec Tool
238 specs (CH/CZ/DE/NORDICS), dark/light theme, cascading filters,
side-by-side comparison, checklist export, admin panel, bulk import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:39:10 +01:00

1185 lines
52 KiB
JavaScript
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.

'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
let allSpecs = [];
let currentSpec = null;
let adminAuthenticated = false;
const ADMIN_PASSWORD = 'loreal2024'; // override via server env in production
// ── Bootstrap ─────────────────────────────────────────────────────────────────
async function init() {
try {
const res = await fetch('specs.json?v=' + Date.now());
const data = await res.json();
allSpecs = data.specs || [];
populateFilters();
renderCards();
renderRecentlyViewed();
checkPermalink();
initDropZone();
} catch (e) {
console.error('Failed to load specs.json', e);
showToast('Failed to load specs', 'error');
}
}
// ── Tab navigation ────────────────────────────────────────────────────────────
function showTab(name) {
['browse', 'admin', 'help'].forEach(t => {
document.getElementById(`tab-${t}-content`).classList.add('hidden');
document.getElementById(`tab-${t}`).classList.remove('active');
});
document.getElementById(`tab-${name}-content`).classList.remove('hidden');
document.getElementById(`tab-${name}`).classList.add('active');
if (name === 'admin') renderAdminTable();
}
// ── Filters ───────────────────────────────────────────────────────────────────
function getFilterValues() {
return {
country: document.getElementById('filterCountry').value,
retailer: document.getElementById('filterRetailer').value,
content: document.getElementById('filterContent').value,
search: document.getElementById('filterSearch').value.trim().toLowerCase(),
};
}
function populateFilters() {
const f = getFilterValues();
// For cascading: filtered by upstream selections
const afterCountry = f.country ? allSpecs.filter(s => s.country === f.country) : allSpecs;
const afterRetailer = f.retailer ? afterCountry.filter(s => s.retailer === f.retailer) : afterCountry;
fillSelect('filterCountry', unique(allSpecs, 'country').sort(), f.country, 'All Countries');
fillSelect('filterRetailer', unique(afterCountry, 'retailer').sort(), f.retailer, 'All Retailers');
fillSelect('filterContent', unique(afterRetailer, 'contentGrouping').sort(), f.content, 'All Types');
}
function fillSelect(id, values, current, placeholder) {
const el = document.getElementById(id);
const prev = current || el.value;
el.innerHTML = `<option value="">${placeholder}</option>`;
values.forEach(v => {
if (!v) return;
const opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
if (v === prev) opt.selected = true;
el.appendChild(opt);
});
}
function unique(specs, key) {
return [...new Set(specs.map(s => s[key]).filter(Boolean))];
}
function onFilterChange() {
populateFilters();
renderCards();
}
function clearFilters() {
document.getElementById('filterCountry').value = '';
document.getElementById('filterRetailer').value = '';
document.getElementById('filterContent').value = '';
document.getElementById('filterSearch').value = '';
populateFilters();
renderCards();
}
function filteredSpecs() {
const { country, retailer, content, search } = getFilterValues();
return allSpecs.filter(s => {
if (country && s.country !== country) return false;
if (retailer && s.retailer !== retailer) return false;
if (content && s.contentGrouping !== content) return false;
if (search) {
const haystack = [s.format, s.retailer, s.dimensions, s.guidelines, s.contentGrouping, s.country]
.join(' ').toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
}
// ── Cards ─────────────────────────────────────────────────────────────────────
function hasActiveFilter() {
const { country, retailer, content, search } = getFilterValues();
return !!(country || retailer || content || search);
}
function renderCards() {
const specs = filteredSpecs();
const grid = document.getElementById('cardsGrid');
const empty = document.getElementById('emptyState');
const prompt = document.getElementById('filterPrompt');
// No filter selected yet — show prompt, hide everything else
if (!hasActiveFilter()) {
grid.innerHTML = '';
empty.classList.add('hidden');
prompt.classList.remove('hidden');
document.getElementById('resultsCount').textContent = '';
document.getElementById('checklistBtn').classList.add('hidden');
return;
}
prompt.classList.add('hidden');
document.getElementById('resultsCount').textContent =
`${specs.length} spec${specs.length !== 1 ? 's' : ''} found`;
// Show checklist button when there are results worth exporting
const checklistBtn = document.getElementById('checklistBtn');
checklistBtn.classList.toggle('hidden', specs.length === 0);
if (!specs.length) {
grid.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
grid.innerHTML = specs.map((s, i) => {
const rv = reviewStatus(s);
return `
<div class="spec-card fade-up" style="animation-delay:${Math.min(i*20, 200)}ms" data-spec-id="${escHtml(s.id)}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
<div style="display:flex;flex-wrap:wrap;gap:6px;">
<span class="badge ${contentBadgeClass(s.contentGrouping)}">${shortContentLabel(s.contentGrouping)}</span>
${rv.status !== 'ok' ? `<span class="badge ${rv.badgeClass}">${rv.label}</span>` : ''}
</div>
<span class="badge badge-country">${s.country || ''}</span>
</div>
<p style="font-weight:600;color:var(--text);font-size:14px;line-height:1.4;margin-bottom:4px;">${escHtml(s.format) || ''}</p>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">${escHtml(s.retailer) || ''}</p>
<div style="display:flex;flex-wrap:wrap;gap:10px;font-size:12px;color:var(--text-muted);">
${s.dimensions && s.dimensions !== 'NO INFO' ? `<span style="display:flex;align-items:center;gap:4px;">
<svg width="11" height="11" fill="none" stroke="#f59e0b" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
${escHtml(s.dimensions)}</span>` : ''}
${s.maxWeight && s.maxWeight !== 'NO INFO' ? `<span style="display:flex;align-items:center;gap:4px;">
<svg width="11" height="11" fill="none" stroke="var(--text-faint)" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
${escHtml(s.maxWeight)}</span>` : ''}
</div>
${s.fileTypes && s.fileTypes.length ? `<div style="margin-top:8px;">${s.fileTypes.map(t => `<span class="file-type-pill">${t}</span>`).join('')}</div>` : ''}
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:8px;">
<button class="card-download-btn" data-dl-id="${escHtml(s.id)}">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
Download
</button>
<button class="card-compare-btn ${selectedForCompare.has(s.id) ? 'active' : ''}" data-cmp-id="${escHtml(s.id)}" title="Add to comparison">
<div class="card-checkbox ${selectedForCompare.has(s.id) ? 'checked' : ''}"></div>
Compare
</button>
</div>
<span style="font-size:12px;color:#f59e0b;font-weight:500;">View full spec →</span>
</div>
</div>
`}).join('');
// Attach click handlers after render (avoids inline JSON issues)
grid.querySelectorAll('.spec-card').forEach(card => {
const id = card.dataset.specId;
const spec = specs.find(s => s.id === id);
if (!spec) return;
// Download button — stops propagation so card modal doesn't open
const dlBtn = card.querySelector('.card-download-btn');
if (dlBtn) dlBtn.addEventListener('click', e => { e.stopPropagation(); downloadSpec(spec); });
// Compare button — stops propagation so card modal doesn't open
const cmpBtn = card.querySelector('.card-compare-btn');
if (cmpBtn) cmpBtn.addEventListener('click', e => toggleCompare(spec.id, e));
// Rest of card opens modal
card.addEventListener('click', () => openModal(spec));
});
}
// ── Review status ─────────────────────────────────────────────────────────────
function reviewStatus(spec) {
const dateStr = spec.lastVerified || spec.updatedAt || '2026-04-27';
const verified = new Date(dateStr);
const nextReview = new Date(verified);
nextReview.setMonth(nextReview.getMonth() + 6);
const today = new Date(); today.setHours(0, 0, 0, 0);
const daysLeft = Math.ceil((nextReview - today) / 86400000);
if (daysLeft < 0) return { status: 'overdue', label: 'Needs Review', badgeClass: 'badge-overdue', daysLeft, nextReview: fmtDate(nextReview) };
if (daysLeft <= 30) return { status: 'due-soon', label: 'Review Soon', badgeClass: 'badge-due-soon', daysLeft, nextReview: fmtDate(nextReview) };
return { status: 'ok', label: 'Verified', badgeClass: 'badge-verified', daysLeft, nextReview: fmtDate(nextReview) };
}
function fmtDate(d) {
return d instanceof Date
? d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: d || '';
}
function contentBadgeClass(cg) {
if (!cg) return 'badge-pdp';
const u = cg.toUpperCase();
if (u.includes('BANNER')) return 'badge-banners';
if (u.includes('BRAND')) return 'badge-brand';
if (u.includes('PDP')) return 'badge-pdp';
if (u.includes('CRM') || u.includes('NEWSLETTER')) return 'badge-crm';
if (u.includes('RETAIL')) return 'badge-retail';
return 'badge-pdp';
}
function shortContentLabel(cg) {
if (!cg) return 'Other';
const u = cg.toUpperCase();
if (u.includes('BANNER')) return 'Banners';
if (u.includes('BRAND')) return 'Brand Store';
if (u.includes('PDP')) return 'PDP';
if (u.includes('CRM') || u.includes('NEWSLETTER')) return 'CRM / Email';
if (u.includes('RETAIL')) return 'Retail Media';
return cg;
}
// ── Spec Modal ────────────────────────────────────────────────────────────────
function openModal(spec) {
currentSpec = spec;
document.getElementById('modalTitle').textContent = spec.format || 'Spec';
document.getElementById('modalSubtitle').textContent = `${spec.retailer || ''} · ${spec.country || ''}`;
// Badges
const rv = reviewStatus(spec);
document.getElementById('modalBadges').innerHTML = `
<span class="badge ${contentBadgeClass(spec.contentGrouping)}">${shortContentLabel(spec.contentGrouping)}</span>
<span class="badge badge-country">${spec.country || ''}</span>
<span class="badge ${rv.badgeClass}">${rv.label}</span>
`;
// Quick stats
const stats = [
{ label: 'Dimensions', value: spec.dimensions && spec.dimensions !== 'NO INFO' ? spec.dimensions : null, icon: 'M3 3h18v18H3z', color: '#f59e0b' },
{ label: 'Max Weight', value: spec.maxWeight && spec.maxWeight !== 'NO INFO' ? spec.maxWeight : null, icon: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4', color: '#34d399' },
{ label: 'File Types', value: spec.fileTypes && spec.fileTypes.length ? spec.fileTypes.join(', ') : null, icon: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z', color: '#818cf8' },
].filter(s => s.value);
document.getElementById('modalQuickStats').innerHTML = stats.map(s => `
<div style="background:#111;border:1px solid #262626;border-radius:8px;padding:12px;">
<p style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#6b7280;margin-bottom:4px;">${s.label}</p>
<p style="font-size:14px;font-weight:600;color:${s.color};">${escHtml(s.value)}</p>
</div>
`).join('');
// Detail rows
const fields = [
{ label: 'Retailer', value: spec.retailer },
{ label: 'Country', value: spec.country },
{ label: 'Content Grouping', value: spec.contentGrouping },
{ label: 'Division', value: spec.division },
{ label: 'Dimensions', value: spec.dimensions },
{ label: 'Max File Weight', value: spec.maxWeight },
{ label: 'File Types', value: spec.fileTypesRaw || (spec.fileTypes || []).join(', ') },
{ label: 'Max Assets', value: spec.maxAssets },
{ label: 'Asset Guidelines', value: spec.guidelines },
{ label: 'Delivery Method', value: spec.deliveryMethod },
{ label: 'Delivery Detail', value: spec.deliveryDetail },
{ label: 'Handled By', value: spec.handledBy },
{ label: 'Naming Convention', value: spec.namingConvention, field: 'namingConvention' },
{ label: 'Upload Link', value: spec.uploadLink },
{ label: 'Notes', value: spec.notes },
{ label: 'Last Updated', value: spec.updatedAt },
{ label: 'Last Verified', value: fmtDate(spec.lastVerified) },
{ label: 'Next Review Due', value: rv.nextReview + (rv.status === 'overdue' ? ' — OVERDUE' : rv.status === 'due-soon' ? `${rv.daysLeft} days` : '') },
];
document.getElementById('modalDetails').innerHTML = fields.map(f => {
const empty = !f.value || ['NO INFO', 'N/A', '-', ''].includes(f.value.trim ? f.value.trim() : '');
const displayVal = empty ? 'No information provided' : escHtml(f.value);
const dataAttr = f.field ? `data-field="${f.field}"` : '';
return `
<div class="spec-detail-row" ${dataAttr}>
<span class="spec-detail-label">${f.label}</span>
<span class="spec-detail-value ${empty ? 'empty' : ''}">${displayVal}</span>
</div>
`;
}).join('');
// Naming convention preview
const namingRow = document.querySelector('.spec-detail-row[data-field="namingConvention"]');
const namingExample = buildNamingExample(spec.namingConvention);
if (namingRow && namingExample) {
const existing = namingRow.querySelector('.naming-preview');
if (!existing) {
const preview = document.createElement('div');
preview.innerHTML = `<p class="naming-preview-label">Example filename</p><div class="naming-preview">${escHtml(namingExample)}</div>`;
namingRow.querySelector('.spec-detail-value').appendChild(preview);
}
}
updateURL(spec.id);
addToRecentlyViewed(spec);
document.getElementById('specModal').classList.remove('hidden');
}
function closeModal(e) {
if (e.target === document.getElementById('specModal')) {
document.getElementById('specModal').classList.add('hidden');
clearURL();
}
}
function closeEditModal(e) {
if (e.target === document.getElementById('editModal')) {
document.getElementById('editModal').classList.add('hidden');
}
}
// ── Export / Download ─────────────────────────────────────────────────────────
function buildExportHTML(s) {
const rv = reviewStatus(s);
const fields = [
['Retailer', s.retailer],
['Country', s.country],
['Content Grouping', s.contentGrouping],
['Dimensions', s.dimensions],
['Max File Weight', s.maxWeight],
['File Types', (s.fileTypes || []).join(', ') || s.fileTypesRaw],
['Max Number of Assets', s.maxAssets],
['Asset Guidelines', s.guidelines],
['Delivery Method', s.deliveryMethod],
['Delivery Detail', s.deliveryDetail],
['Naming Convention', s.namingConvention],
['Notes', s.notes],
['Last Verified', fmtDate(s.lastVerified)],
['Next Review Due', rv.nextReview],
].filter(([, v]) => v && !['NO INFO', 'N/A', '', ''].includes(String(v).trim()));
const reviewBadge = rv.status === 'ok'
? `<span style="background:#f0fdf4;color:#16a34a;border:1px solid #bbf7d0;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:0.05em;">Verified</span>`
: rv.status === 'due-soon'
? `<span style="background:#fffbeb;color:#d97706;border:1px solid #fde68a;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:0.05em;">Review Soon</span>`
: `<span style="background:#fef2f2;color:#dc2626;border:1px solid #fecaca;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:0.05em;">Needs Review</span>`;
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:680px;margin:0 auto;padding:32px 24px;color:#111;background:#fff;">
<div style="border-bottom:2px solid #f59e0b;padding-bottom:16px;margin-bottom:20px;">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#f59e0b;margin:0 0 8px;">L'Oréal Spec Tool</p>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
<div>
<h1 style="font-size:19px;font-weight:700;color:#111;margin:0 0 4px;line-height:1.3;">${escHtml(s.format)}</h1>
<p style="font-size:13px;color:#6b7280;margin:0;">${escHtml(s.retailer)} · ${escHtml(s.country)} · ${escHtml(shortContentLabel(s.contentGrouping))}</p>
</div>
${reviewBadge}
</div>
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px;">
${fields.map(([label, value]) => `
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;padding:9px 12px 9px 0;width:170px;vertical-align:top;">${label}</td>
<td style="padding:9px 0;color:#111;line-height:1.6;vertical-align:top;">${escHtml(value)}</td>
</tr>
`).join('')}
</table>
<p style="font-size:10px;color:#d1d5db;margin-top:20px;border-top:1px solid #f3f4f6;padding-top:12px;">
Generated by L'Oréal Spec Tool · ${new Date().toLocaleDateString('en-GB', { day:'2-digit', month:'long', year:'numeric' })}
</p>
</div>
`;
}
function renderAndDownload(spec) {
const printArea = document.getElementById('printArea');
printArea.innerHTML = buildExportHTML(spec);
printArea.style.display = 'block';
html2canvas(printArea, { scale: 2, backgroundColor: '#ffffff', logging: false }).then(canvas => {
printArea.style.display = 'none';
const link = document.createElement('a');
link.download = `loreal-spec-${slugify(spec.retailer)}-${slugify(spec.format)}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
showToast('Spec downloaded', 'success');
}).catch(() => {
printArea.style.display = 'none';
window.print();
});
}
function exportPDF() {
if (!currentSpec) return;
renderAndDownload(currentSpec);
}
function downloadSpec(spec) {
renderAndDownload(spec);
}
function copySpecText() {
if (!currentSpec) return;
const s = currentSpec;
const lines = [
`SPEC: ${s.format}`,
`Retailer: ${s.retailer} (${s.country})`,
`Content Type: ${s.contentGrouping}`,
`Dimensions: ${s.dimensions}`,
`Max Weight: ${s.maxWeight}`,
`File Types: ${(s.fileTypes || []).join(', ')}`,
`Max Assets: ${s.maxAssets}`,
`Guidelines: ${s.guidelines}`,
`Delivery: ${s.deliveryDetail}`,
`Naming: ${s.namingConvention}`,
].filter(l => !l.endsWith(': ') && !l.includes('NO INFO')).join('\n');
navigator.clipboard.writeText(lines).then(() => {
showToast('Copied to clipboard', 'success');
});
}
// ── Admin ─────────────────────────────────────────────────────────────────────
function checkAdminPassword() {
const input = document.getElementById('adminPasswordInput').value;
if (input === ADMIN_PASSWORD) {
adminAuthenticated = true;
document.getElementById('adminPasswordOverlay').classList.add('hidden');
document.getElementById('adminPanel').classList.remove('hidden');
renderAdminTable();
} else {
document.getElementById('adminPasswordError').classList.remove('hidden');
document.getElementById('adminPasswordInput').value = '';
}
}
function toggleAddForm() {
const form = document.getElementById('addForm');
const btn = document.getElementById('toggleAddBtn');
const hidden = form.classList.contains('hidden');
form.classList.toggle('hidden', !hidden);
btn.textContent = hidden ? 'Hide form' : 'Show form';
}
function addSpec() {
const country = document.getElementById('newCountry').value.trim();
const retailer = document.getElementById('newRetailer').value.trim();
const format = document.getElementById('newFormat').value.trim();
if (!country || !retailer || !format) {
showToast('Country, Retailer and Format are required', 'error');
return;
}
const fileTypesRaw = document.getElementById('newFileTypes').value.trim();
const newSpec = {
id: crypto.randomUUID(),
country,
retailer,
contentGrouping: document.getElementById('newContentGrouping').value.trim(),
division: '',
format,
dimensions: document.getElementById('newDimensions').value.trim(),
maxWeight: document.getElementById('newMaxWeight').value.trim(),
fileTypes: fileTypesRaw ? fileTypesRaw.split(/[,\s]+/).map(s => s.toUpperCase()).filter(Boolean) : [],
fileTypesRaw,
maxAssets: document.getElementById('newMaxAssets').value.trim(),
guidelines: document.getElementById('newGuidelines').value.trim(),
deliveryMethod: 'MANUAL',
deliveryDetail: document.getElementById('newDeliveryDetail').value.trim(),
handledBy: '',
namingConvention:document.getElementById('newNamingConvention').value.trim(),
uploadLink: '',
imageExample: '',
notes: document.getElementById('newNotes').value.trim(),
updatedAt: new Date().toISOString().split('T')[0],
lastVerified: new Date().toISOString().split('T')[0],
};
allSpecs.push(newSpec);
saveSpecs(() => {
clearAddForm();
renderCards();
renderAdminTable();
showToast('Spec added', 'success');
});
}
function clearAddForm() {
['newCountry','newRetailer','newContentGrouping','newFormat','newDimensions',
'newMaxWeight','newFileTypes','newMaxAssets','newGuidelines','newDeliveryDetail',
'newNamingConvention','newNotes'].forEach(id => {
document.getElementById(id).value = '';
});
}
function renderAdminTable() {
if (!adminAuthenticated) return;
const search = (document.getElementById('adminSearch')?.value || '').toLowerCase();
const specs = search
? allSpecs.filter(s => [s.format, s.retailer, s.country, s.contentGrouping].join(' ').toLowerCase().includes(search))
: allSpecs;
document.getElementById('adminSpecCount').textContent = allSpecs.length;
document.getElementById('adminTableBody').innerHTML = specs.map(s => {
const rv = reviewStatus(s);
return `
<tr>
<td><span class="badge badge-country">${escHtml(s.country)}</span></td>
<td style="color:var(--text);">${escHtml(s.retailer)}</td>
<td><span class="badge ${contentBadgeClass(s.contentGrouping)}">${shortContentLabel(s.contentGrouping)}</span></td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-sub);" title="${escHtml(s.format)}">${escHtml(s.format)}</td>
<td style="color:var(--text-muted);">${escHtml(s.dimensions) || ''}</td>
<td>${(s.fileTypes || []).map(t => `<span class="file-type-pill">${t}</span>`).join('')}</td>
<td>
<span class="badge ${rv.badgeClass}" style="font-size:10px;">${rv.label}</span>
<span style="font-size:11px;color:var(--text-faint);display:block;margin-top:3px;">Due ${rv.nextReview}</span>
</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="btn-verify" onclick="markVerified('${s.id}')">✓ Verify</button>
<button class="btn-edit" onclick="openEditModal('${s.id}')">Edit</button>
<button class="btn-danger" onclick="deleteSpec('${s.id}')">Delete</button>
</div>
</td>
</tr>
`}).join('');
}
function deleteSpec(id) {
if (!confirm('Delete this spec? This cannot be undone.')) return;
allSpecs = allSpecs.filter(s => s.id !== id);
saveSpecs(() => {
renderCards();
renderAdminTable();
showToast('Spec deleted', 'success');
});
}
function markVerified(id) {
const idx = allSpecs.findIndex(s => s.id === id);
if (idx === -1) return;
const today = new Date().toISOString().split('T')[0];
allSpecs[idx] = { ...allSpecs[idx], lastVerified: today, updatedAt: today };
saveSpecs(() => {
renderCards();
renderAdminTable();
showToast('Spec marked as verified', 'success');
});
}
function openEditModal(id) {
const spec = allSpecs.find(s => s.id === id);
if (!spec) return;
document.getElementById('editSpecId').value = spec.id;
document.getElementById('editCountry').value = spec.country || '';
document.getElementById('editRetailer').value = spec.retailer || '';
document.getElementById('editContentGrouping').value = spec.contentGrouping || '';
document.getElementById('editFormat').value = spec.format || '';
document.getElementById('editDimensions').value = spec.dimensions || '';
document.getElementById('editMaxWeight').value = spec.maxWeight || '';
document.getElementById('editFileTypes').value = (spec.fileTypes || []).join(', ');
document.getElementById('editMaxAssets').value = spec.maxAssets || '';
document.getElementById('editGuidelines').value = spec.guidelines || '';
document.getElementById('editDeliveryDetail').value = spec.deliveryDetail || '';
document.getElementById('editNamingConvention').value= spec.namingConvention || '';
document.getElementById('editNotes').value = spec.notes || '';
document.getElementById('editModal').classList.remove('hidden');
}
function saveEditSpec() {
const id = document.getElementById('editSpecId').value;
const idx = allSpecs.findIndex(s => s.id === id);
if (idx === -1) return;
const fileTypesRaw = document.getElementById('editFileTypes').value.trim();
allSpecs[idx] = {
...allSpecs[idx],
country: document.getElementById('editCountry').value.trim(),
retailer: document.getElementById('editRetailer').value.trim(),
contentGrouping: document.getElementById('editContentGrouping').value.trim(),
format: document.getElementById('editFormat').value.trim(),
dimensions: document.getElementById('editDimensions').value.trim(),
maxWeight: document.getElementById('editMaxWeight').value.trim(),
fileTypes: fileTypesRaw ? fileTypesRaw.split(/[,\s]+/).map(s => s.toUpperCase()).filter(Boolean) : [],
fileTypesRaw,
maxAssets: document.getElementById('editMaxAssets').value.trim(),
guidelines: document.getElementById('editGuidelines').value.trim(),
deliveryDetail: document.getElementById('editDeliveryDetail').value.trim(),
namingConvention:document.getElementById('editNamingConvention').value.trim(),
notes: document.getElementById('editNotes').value.trim(),
updatedAt: new Date().toISOString().split('T')[0],
};
saveSpecs(() => {
document.getElementById('editModal').classList.add('hidden');
renderCards();
renderAdminTable();
showToast('Spec updated', 'success');
});
}
// ── Comparison ────────────────────────────────────────────────────────────────
const selectedForCompare = new Set();
function toggleCompare(specId, e) {
e.stopPropagation();
if (selectedForCompare.has(specId)) {
selectedForCompare.delete(specId);
} else {
if (selectedForCompare.size >= 3) {
showToast('Maximum 3 specs at once', 'error');
return;
}
selectedForCompare.add(specId);
}
updateCompareBar();
updateCardSelection();
}
function updateCardSelection() {
document.querySelectorAll('.spec-card').forEach(card => {
const id = card.dataset.specId;
const checkbox = card.querySelector('.card-checkbox');
const cmpBtn = card.querySelector('.card-compare-btn');
const selected = selectedForCompare.has(id);
card.classList.toggle('selected', selected);
if (checkbox) checkbox.classList.toggle('checked', selected);
if (cmpBtn) cmpBtn.classList.toggle('active', selected);
});
}
function updateCompareBar() {
const count = selectedForCompare.size;
const bar = document.getElementById('compareBar');
document.getElementById('compareCount').textContent = count;
document.getElementById('compareBtn').disabled = count < 2;
bar.classList.toggle('visible', count > 0);
// Chips
const specs = [...selectedForCompare].map(id => allSpecs.find(s => s.id === id)).filter(Boolean);
document.getElementById('compareChips').innerHTML = specs.map(s => `
<div class="compare-chip">
<span class="compare-chip-name" title="${escHtml(s.format)}">${escHtml(s.format)}</span>
<span class="compare-chip-remove" onclick="toggleCompare('${s.id}', event)">✕</span>
</div>
`).join('');
}
function clearComparison() {
selectedForCompare.clear();
updateCompareBar();
updateCardSelection();
}
function openComparison() {
if (selectedForCompare.size < 2) return;
const specs = [...selectedForCompare].map(id => allSpecs.find(s => s.id === id)).filter(Boolean);
const fields = [
{ label: 'Retailer', key: 'retailer' },
{ label: 'Country', key: 'country' },
{ label: 'Content Type', key: 'contentGrouping' },
{ label: 'Dimensions', key: 'dimensions' },
{ label: 'Max File Weight', key: 'maxWeight' },
{ label: 'File Types', key: null, fn: s => (s.fileTypes || []).join(', ') || s.fileTypesRaw || '' },
{ label: 'Max Assets', key: 'maxAssets' },
{ label: 'Asset Guidelines', key: 'guidelines' },
{ label: 'Delivery', key: 'deliveryDetail' },
{ label: 'Naming Convention', key: 'namingConvention' },
{ label: 'Review Status', key: null, fn: s => reviewStatus(s).label },
{ label: 'Last Verified', key: 'lastVerified' },
];
const isNoInfo = v => !v || ['NO INFO', 'N/A', '-', ''].includes(String(v).trim());
const display = v => isNoInfo(v) ? '' : escHtml(String(v));
// Header row
const headerCells = specs.map(s => `
<th>
<div style="font-size:13px;font-weight:700;color:var(--text);text-transform:none;letter-spacing:0;">${escHtml(s.format)}</div>
<div style="font-size:11px;color:var(--text-muted);font-weight:400;margin-top:2px;">${escHtml(s.retailer)} · ${escHtml(s.country)}</div>
</th>
`).join('');
// Data rows
const dataRows = fields.map(f => {
const values = specs.map(s => {
const raw = f.fn ? f.fn(s) : (s[f.key] || '');
return isNoInfo(raw) ? '' : String(raw);
});
// Highlight if not all values are equal
const allSame = values.every(v => v === values[0]);
const cells = values.map(v => {
const isEmpty = !v;
return `<td class="${!allSame && !isEmpty ? 'diff' : ''}${isEmpty ? ' empty-val' : ''}">${isEmpty ? '' : escHtml(v)}</td>`;
}).join('');
return `<tr>
<td class="row-label">${f.label}</td>
${cells}
</tr>`;
}).join('');
document.getElementById('compareTable').innerHTML = `
<thead><tr><th style="width:140px;"></th>${headerCells}</tr></thead>
<tbody>${dataRows}</tbody>
`;
document.getElementById('compareModal').classList.remove('hidden');
}
function closeCompareModal(e) {
if (e.target === document.getElementById('compareModal')) {
document.getElementById('compareModal').classList.add('hidden');
}
}
// ── Checklist Export ──────────────────────────────────────────────────────────
function openChecklist() {
const { country, retailer, content } = getFilterValues();
const specs = filteredSpecs();
if (!specs.length) return;
const title = [retailer, country, content].filter(Boolean).join(' · ');
document.getElementById('checklistTitle').textContent = title;
// Group by content grouping
const groups = {};
specs.forEach(s => {
const g = s.contentGrouping || 'Other';
if (!groups[g]) groups[g] = [];
groups[g].push(s);
});
const today = new Date().toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' });
const html = `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111;">
<!-- Header -->
<div style="border-bottom:3px solid #f59e0b;padding-bottom:20px;margin-bottom:28px;">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#f59e0b;margin:0 0 8px;">L'Oréal Spec Tool — Production Checklist</p>
<h1 style="font-size:24px;font-weight:700;color:#111;margin:0 0 6px;line-height:1.2;">${escHtml(retailer || 'All Retailers')}</h1>
<div style="display:flex;flex-wrap:wrap;gap:16px;font-size:13px;color:#6b7280;">
${country ? `<span>Country: <strong style="color:#111;">${escHtml(country)}</strong></span>` : ''}
${content ? `<span>Type: <strong style="color:#111;">${escHtml(content)}</strong></span>` : ''}
<span>Total formats: <strong style="color:#111;">${specs.length}</strong></span>
<span>Generated: <strong style="color:#111;">${today}</strong></span>
</div>
</div>
<!-- Groups -->
${Object.entries(groups).map(([groupName, groupSpecs]) => `
<div style="margin-bottom:32px;page-break-inside:avoid;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #e5e7eb;">
<h2 style="font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#f59e0b;margin:0;">${escHtml(groupName)}</h2>
<span style="font-size:11px;color:#9ca3af;">${groupSpecs.length} format${groupSpecs.length !== 1 ? 's' : ''}</span>
</div>
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead>
<tr style="background:#f9fafb;">
<th style="width:24px;padding:8px 10px;"></th>
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Format</th>
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Dimensions</th>
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">File Types</th>
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Max Size</th>
<th style="text-align:left;padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;">Delivery</th>
</tr>
</thead>
<tbody>
${groupSpecs.map((s, i) => `
<tr style="border-bottom:1px solid #f3f4f6;${i % 2 === 0 ? '' : 'background:#fafafa;'}">
<td style="padding:10px 10px;vertical-align:top;">
<div style="width:16px;height:16px;border:2px solid #d1d5db;border-radius:3px;"></div>
</td>
<td style="padding:10px 10px;vertical-align:top;">
<p style="font-weight:600;color:#111;margin:0 0 2px;line-height:1.4;">${escHtml(s.format)}</p>
${s.guidelines && s.guidelines !== 'NO INFO' ? `<p style="font-size:11px;color:#6b7280;margin:0;line-height:1.4;">${escHtml(s.guidelines.length > 120 ? s.guidelines.slice(0,120) + '…' : s.guidelines)}</p>` : ''}
</td>
<td style="padding:10px 10px;vertical-align:top;font-weight:600;color:${s.dimensions && s.dimensions !== 'NO INFO' ? '#f59e0b' : '#d1d5db'};">
${s.dimensions && s.dimensions !== 'NO INFO' ? escHtml(s.dimensions) : ''}
</td>
<td style="padding:10px 10px;vertical-align:top;color:#374151;">
${(s.fileTypes || []).join(', ') || (s.fileTypesRaw && s.fileTypesRaw !== 'NO INFO' ? escHtml(s.fileTypesRaw) : '')}
</td>
<td style="padding:10px 10px;vertical-align:top;color:#374151;">
${s.maxWeight && s.maxWeight !== 'NO INFO' ? escHtml(s.maxWeight) : ''}
</td>
<td style="padding:10px 10px;vertical-align:top;font-size:11px;color:#6b7280;">
${s.deliveryDetail && s.deliveryDetail !== 'NO INFO' ? escHtml(s.deliveryDetail.length > 60 ? s.deliveryDetail.slice(0,60) + '…' : s.deliveryDetail) : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`).join('')}
<!-- Footer -->
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;">
<p style="font-size:10px;color:#d1d5db;margin:0;">Generated by L'Oréal Spec Tool · ${today}</p>
<p style="font-size:10px;color:#d1d5db;margin:0;">□ = Not started &nbsp; ✓ = Complete &nbsp; — = N/A</p>
</div>
</div>
`;
document.getElementById('checklistContent').innerHTML = html;
document.getElementById('checklistOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeChecklist() {
document.getElementById('checklistOverlay').classList.remove('open');
document.body.style.overflow = '';
}
// ── Bulk Import ───────────────────────────────────────────────────────────────
let importParsed = [];
function handleImportFile(file) {
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!['xlsx', 'xls', 'csv'].includes(ext)) {
showToast('Please upload an Excel (.xlsx) or CSV file', 'error');
return;
}
setImportStatus(`Reading ${file.name}`);
document.getElementById('importPreview').classList.add('hidden');
document.getElementById('importFileInput').value = '';
const reader = new FileReader();
reader.onload = e => {
try {
const data = new Uint8Array(e.target.result);
const wb = XLSX.read(data, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
// Find header row: must contain at least 2 of these column names as distinct cells
const HEADER_SIGNALS = ['COUNTRY','RETAILER','FORMAT','DIMENSION','GUIDELINES'];
let headerIdx = 0;
for (let i = 0; i < Math.min(6, raw.length); i++) {
const cells = raw[i].map(c => String(c).toUpperCase().trim());
const hits = HEADER_SIGNALS.filter(s => cells.some(c => c.includes(s)));
if (hits.length >= 2) { headerIdx = i; break; }
}
const headers = raw[headerIdx].map(h => String(h).trim());
const dataRows = raw.slice(headerIdx + 1).filter(r => r.some(c => c !== ''));
const today = new Date().toISOString().split('T')[0];
importParsed = dataRows
.map(r => Object.fromEntries(headers.map((h, i) => [h, String(r[i] || '').trim()])))
.filter(r => getImportField(r, ['RETAILER','retailer','Retailer']) ||
getImportField(r, ['FORMAT','format','Format','FORMAT NAME']))
.map(r => {
const fileTypesRaw = getImportField(r, ['FILE TYPE','file type','File Type','fileType']);
return {
id: crypto.randomUUID(),
country: getImportField(r, ['COUNTRY','country','Country']),
retailer: getImportField(r, ['RETAILER','retailer','Retailer']),
contentGrouping: getImportField(r, ['ECOM CONTENT GROUPING','Content Grouping','contentGrouping','CONTENT GROUPING','category','Category']),
division: getImportField(r, ['DIVISION','division','Division']),
format: getImportField(r, ['FORMAT','format','Format','FORMAT NAME','format name','Format Name']),
dimensions: getImportField(r, ['ASSET DIMENSION','dimensions','Dimensions','dimension','size','Size']),
maxWeight: getImportField(r, ['ASSET WEIGHT','maxWeight','max weight','Max Weight','file size','File Size']),
fileTypes: splitImportFileTypes(fileTypesRaw),
fileTypesRaw,
maxAssets: getImportField(r, ['MAX NUMBER OF ASSETS','maxAssets','max assets','Max Assets']),
guidelines: getImportField(r, ['RETAILER ASSET GUIDELINES','guidelines','Guidelines','GUIDELINES']),
deliveryMethod: getImportField(r, ['DELIVERY METHOD FOR SYNDICATION','deliveryMethod','delivery method']),
deliveryDetail: getImportField(r, ['DETAILED','deliveryDetail','delivery detail','Delivery Detail']),
handledBy: getImportField(r, ['HANDLED BY (WIP)','handledBy','handled by']),
namingConvention:getImportField(r, ['FILE NAMING CONVENTION FOR RETAILER','namingConvention','naming convention','Naming Convention']),
uploadLink: getImportField(r, ['LINKS FOR UPLOAD','uploadLink','upload link']),
imageExample: getImportField(r, ['IMAGE EXAMPLE','imageExample','image example']),
notes: getImportField(r, ['notes','Notes','NOTES']),
updatedAt: today,
lastVerified: today,
};
});
if (!importParsed.length) {
setImportStatus('No valid specs found — check the file has RETAILER and FORMAT columns.');
return;
}
showImportPreview(importParsed);
setImportStatus(`Parsed ${importParsed.length} specs from ${file.name}`);
} catch (err) {
setImportStatus(`Could not read file: ${err.message}`);
showToast('Failed to parse file', 'error');
}
};
reader.readAsArrayBuffer(file);
}
function getImportField(row, keys) {
for (const k of keys) {
const v = String(row[k] || '').trim();
if (v && !['NO INFO', 'N/A', '-'].includes(v)) return v;
}
return '';
}
function splitImportFileTypes(raw) {
if (!raw) return [];
const known = new Set(['JPG','JPEG','PNG','TIFF','TIF','PSD','WEBP','SVG','ZIP','PDF','GIF','MP4','MOV','AVI']);
return [...new Set(
raw.toUpperCase().split(/[\s,/|]+/)
.map(p => p.replace(/[^A-Z]/g, ''))
.filter(p => known.has(p))
)];
}
function showImportPreview(parsed) {
let newCount = 0, updatedCount = 0, unchangedCount = 0;
parsed.forEach(incoming => {
const existing = allSpecs.find(s =>
s.retailer?.toLowerCase() === incoming.retailer?.toLowerCase() &&
s.format?.toLowerCase() === incoming.format?.toLowerCase()
);
if (!existing) {
newCount++;
} else {
const changed = ['dimensions','maxWeight','fileTypesRaw','guidelines','deliveryDetail','namingConvention']
.some(k => (incoming[k] || '') !== (existing[k] || ''));
changed ? updatedCount++ : unchangedCount++;
}
});
document.getElementById('importCountNew').textContent = newCount;
document.getElementById('importCountUpdated').textContent = updatedCount;
document.getElementById('importCountUnchanged').textContent = unchangedCount;
document.getElementById('importPreview').classList.remove('hidden');
}
function confirmImport() {
if (!importParsed.length) return;
const today = new Date().toISOString().split('T')[0];
const merged = [...allSpecs];
importParsed.forEach(incoming => {
const idx = merged.findIndex(s =>
s.retailer?.toLowerCase() === incoming.retailer?.toLowerCase() &&
s.format?.toLowerCase() === incoming.format?.toLowerCase()
);
if (idx === -1) {
merged.push(incoming);
} else {
// Update fields but preserve id and lastVerified
merged[idx] = { ...merged[idx], ...incoming, id: merged[idx].id, lastVerified: merged[idx].lastVerified, updatedAt: today };
}
});
allSpecs = merged;
saveSpecs(() => {
cancelImport();
renderCards();
renderAdminTable();
showToast(`Import complete — ${importParsed.length} specs processed`, 'success');
});
}
function cancelImport() {
importParsed = [];
document.getElementById('importPreview').classList.add('hidden');
setImportStatus('');
document.getElementById('importFileInput').value = '';
}
function setImportStatus(msg) {
document.getElementById('importStatus').textContent = msg;
}
// Drag-and-drop wiring (runs after DOM ready)
function initDropZone() {
const zone = document.getElementById('dropZone');
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) handleImportFile(file);
});
}
// ── Persistence ───────────────────────────────────────────────────────────────
async function saveSpecs(callback) {
const payload = {
specs: allSpecs,
meta: {
version: '1.0',
lastUpdated: new Date().toISOString().split('T')[0],
totalSpecs: allSpecs.length,
}
};
try {
const res = await fetch('/api/specs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': ADMIN_PASSWORD,
},
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
if (callback) callback();
} catch (e) {
console.error('Save failed:', e);
// Still call callback for local UI update
if (callback) callback();
showToast('Saved locally (server unavailable)', 'error');
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
setTimeout(() => { t.className = ''; }, 3000);
}
// ── Permalinks ────────────────────────────────────────────────────────────────
function updateURL(specId) {
const url = new URL(window.location);
url.searchParams.set('spec', specId);
history.replaceState(null, '', url);
}
function clearURL() {
const url = new URL(window.location);
url.searchParams.delete('spec');
history.replaceState(null, '', url);
}
function copySpecLink() {
if (!currentSpec) return;
const url = new URL(window.location);
url.searchParams.set('spec', currentSpec.id);
navigator.clipboard.writeText(url.toString()).then(() => {
showToast('Link copied to clipboard', 'success');
});
}
function checkPermalink() {
const id = new URLSearchParams(window.location.search).get('spec');
if (!id) return;
const spec = allSpecs.find(s => s.id === id);
if (spec) openModal(spec);
}
// ── Recently viewed ───────────────────────────────────────────────────────────
const RECENT_KEY = 'specTool_recentlyViewed';
const RECENT_MAX = 5;
function addToRecentlyViewed(spec) {
let ids = getRecentIds();
ids = [spec.id, ...ids.filter(id => id !== spec.id)].slice(0, RECENT_MAX);
localStorage.setItem(RECENT_KEY, JSON.stringify(ids));
renderRecentlyViewed();
}
function getRecentIds() {
try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; }
catch { return []; }
}
function renderRecentlyViewed() {
const ids = getRecentIds();
const specs = ids.map(id => allSpecs.find(s => s.id === id)).filter(Boolean);
const section = document.getElementById('recentSection');
const strip = document.getElementById('recentStrip');
if (!specs.length) { section.classList.add('hidden'); return; }
section.classList.remove('hidden');
strip.innerHTML = specs.map(s => `
<div class="recent-chip" data-recent-id="${escHtml(s.id)}">
<div class="recent-chip-title">${escHtml(s.format)}</div>
<div class="recent-chip-sub">${escHtml(s.retailer)} · ${escHtml(s.country)}</div>
</div>
`).join('');
strip.querySelectorAll('.recent-chip').forEach(chip => {
const spec = specs.find(s => s.id === chip.dataset.recentId);
if (spec) chip.addEventListener('click', () => openModal(spec));
});
}
// ── Naming convention preview ─────────────────────────────────────────────────
const NAMING_SUBSTITUTIONS = {
ManufacturerName: 'LOreal',
manufacturername: 'loreal',
ProductName: 'Elvive',
productname: 'elvive',
BannerType: 'HomepageBanner',
bannertype: 'homepage-banner',
BrandName: 'LOreal',
brandname: 'loreal',
bannername: 'loreal-elvive-homepage',
Category: 'HairCare',
category: 'haircare',
Format: 'Banner',
Width: '1200',
width: '1200',
Height: '628',
height: '628',
Country: 'DE',
country: 'de',
Year: '2026',
year: '2026',
};
function buildNamingExample(convention) {
if (!convention || ['NO INFO', 'N/A', '-'].includes(convention.trim())) return null;
let example = convention;
Object.entries(NAMING_SUBSTITUTIONS).forEach(([placeholder, value]) => {
example = example.replace(new RegExp(placeholder, 'g'), value);
});
// If nothing changed the convention had no recognisable placeholders — still useful to show as-is
return example !== convention ? example : null;
}
// ── Theme ──────────────────────────────────────────────────────────────────────
function toggleTheme() {
const isLight = document.body.classList.toggle('light');
localStorage.setItem('specToolTheme', isLight ? 'light' : 'dark');
document.getElementById('iconDark').style.display = isLight ? 'none' : '';
document.getElementById('iconLight').style.display = isLight ? '' : 'none';
}
function applyStoredTheme() {
const stored = localStorage.getItem('specToolTheme');
if (stored === 'light') {
document.body.classList.add('light');
document.getElementById('iconDark').style.display = 'none';
document.getElementById('iconLight').style.display = '';
}
}
// Keyboard shortcut: Escape closes modals
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.getElementById('specModal').classList.add('hidden');
document.getElementById('editModal').classList.add('hidden');
clearURL();
}
});
applyStoredTheme();
init();