Team Liquid — Worlds 2026 Social CampaignTeam Liquid — Worlds 2026 Social Campaign
New board. Hit + New Brief to add a job — link each one to its own HTML file made from job.html. Already have a backup?
No changes yet· 0.1 MB (1%)
Live Creative Briefs

Prove Your Skills

Real briefs from the world's top esports organisations. Pick a job, read the full spec, start the clock, and deliver professional creative work under real pressure.

1 LIVE BRIEFS · 1 TEAMS
1
Partner
Teams
1
Live
Briefs
1
Job
Categories
Practice
Runs
Brief deleted
]*>/, ' '); // Build the data payload — full state including password const fullState = { jobs: state.jobs, currentJobId: null, currentSubpage: 0, activeCat: "All", activeTeam: "All", a11y: state.a11y || { fs: 1, motion: false }, tabNames: state.tabNames || [...DEFAULT_TAB_NAMES], teamLogos: state.teamLogos || {}, teamColors: state.teamColors || {}, passwordHash: state.passwordHash || null, savedAt: Date.now() }; const jsonStr = JSON.stringify(fullState); const dataB64 = btoa(unescape(encodeURIComponent(jsonStr))); // Avoid any literal closing-script-tag string in this source code by string concat const SCRIPT_OPEN = ''; const SCRIPT_CLOSE = ''; const SCRIPT_OCTET = ''; const injection = SCRIPT_OCTET + dataB64 + SCRIPT_CLOSE + '\n' + SCRIPT_OPEN + '(function(){' + 'try{' + 'var el=document.getElementById("brief-gg-data");' + 'if(!el)return;' + 'var json=decodeURIComponent(escape(atob(el.textContent.trim())));' + 'window.__BRIEF_GG_DATA__=JSON.parse(json);' + 'window.__BRIEF_GG_EDITABLE_BACKUP__=true;' + '}catch(e){console.error("Backup data load failed",e);}' + '})();' + SCRIPT_CLOSE + '\n'; // Insert just before html = html.replace("", injection + ""); // Restore the editor view if (prevJobId && prevView === "job") { state.currentJobId = prevJobId; state.currentSubpage = prevSubpage; openJob(prevJobId); } else if (prevView === "jobs") { goJobs(); } callback(html); } catch (err) { console.error("buildEditableBackupHtml failed:", err); toast("Backup build failed", true); } }, 80); } function exportAsWebsite() { // Flush focused input + cancel pending saves so latest edits are in state if (document.activeElement && document.activeElement.blur) { document.activeElement.blur(); } clearTimeout(saveTimer); setTimeout(() => { buildWebsiteHtml((html) => { const blob = new Blob([html], { type: "text/html;charset=utf-8" }); const url = URL.createObjectURL(blob); const newTab = window.open(url, "_blank"); if (!newTab) { downloadBlob(blob, `brief-gg-website-${new Date().toISOString().slice(0,10)}.html`); toast("Popup blocked — downloaded instead"); } else { toast(`Site opened · ${state.jobs.length} briefs`); } setTimeout(() => URL.revokeObjectURL(url), 60000); }); }, 60); } // ============ PDF EXPORT (per brief) ============ // Generates a polished PDF for the current brief — cover page with team logo, // then each tab as its own structured section with proper typography and page footers. async function downloadBriefPdf() { if (typeof window.jspdf === "undefined" && typeof window.jsPDF === "undefined") { toast("PDF library not loaded — check internet", true); return; } const jsPDFCtor = (window.jspdf && window.jspdf.jsPDF) || window.jsPDF; if (!jsPDFCtor) { toast("PDF library missing", true); return; } const j = currentJob(); if (!j) { toast("No brief open", true); return; } toast("Building PDF — one moment…"); // Give the toast a tick to show await new Promise(r => setTimeout(r, 60)); try { const doc = new jsPDFCtor({ unit: "pt", format: "a4", orientation: "portrait" }); // ----- A4 = 595 × 842 pt ----- const PAGE_W = 595; const PAGE_H = 842; const MARGIN = 50; const COL_W = PAGE_W - MARGIN * 2; // Team brand colour as RGB (fall back to mint) const brand = hexToRgb(teamColor(j.team)) || [79, 255, 162]; // Fonts (jsPDF default — Helvetica family. Real custom fonts need a separate font file.) // We use Helvetica weights for headings/body. // Helpers for drawing function setFill(rgb) { doc.setFillColor(rgb[0], rgb[1], rgb[2]); } function setStroke(rgb) { doc.setDrawColor(rgb[0], rgb[1], rgb[2]); } function setText(rgb) { doc.setTextColor(rgb[0], rgb[1], rgb[2]); } function drawPageHeader(pageNum, totalPages) { // Thin top rule setStroke([220, 220, 220]); doc.setLineWidth(0.5); doc.line(MARGIN, 28, PAGE_W - MARGIN, 28); } function drawPageFooter(pageNum, totalPages) { setStroke([220, 220, 220]); doc.setLineWidth(0.5); doc.line(MARGIN, PAGE_H - 36, PAGE_W - MARGIN, PAGE_H - 36); doc.setFont("helvetica", "normal"); doc.setFontSize(8); setText([120, 120, 120]); const leftText = `BRIEF.GG · ${(j.team || "").toUpperCase()} · ${j.num || ""}`; doc.text(leftText, MARGIN, PAGE_H - 22); const rightText = `Page ${pageNum} of ${totalPages}`; const rw = doc.getTextWidth(rightText); doc.text(rightText, PAGE_W - MARGIN - rw, PAGE_H - 22); } // Load an image as base64 for jsPDF async function imgToData(src) { if (!src) return null; // jsPDF can handle data URLs directly if (src.startsWith("data:")) return src; // If it's a relative URL (from ZIP export), try fetching it try { const resp = await fetch(src); const blob = await resp.blob(); return await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = () => res(reader.result); reader.onerror = rej; reader.readAsDataURL(blob); }); } catch(e) { return null; } } function dataMime(dataUrl) { const m = dataUrl && dataUrl.match(/^data:([^;]+);/); if (!m) return "PNG"; if (m[1].includes("jpeg") || m[1].includes("jpg")) return "JPEG"; if (m[1].includes("png")) return "PNG"; if (m[1].includes("webp")) return "WEBP"; return "PNG"; } // Get image dimensions from a data URL function getImgDims(dataUrl) { return new Promise(resolve => { const img = new Image(); img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight }); img.onerror = () => resolve({ w: 1000, h: 600 }); img.src = dataUrl; }); } // ----- COVER PAGE ----- // Layout philosophy: full-bleed banner across the top (no separate stripe). // Logo sits on the banner, overlapping into the white area below. // Title and meta live in the lower half on a clean white field. let pageNum = 1; // Banner height matches website's ~3.75:1 wide-and-short aspect on a 595pt page width // Website: ~240px tall on ~900px wide container. PDF: 595 / 3.75 ≈ 159 → use 170 for a touch more presence const BANNER_H = 170; // Banner image OR brand-coloured solid hero if (j.banner) { const banner = await imgToData(j.banner); if (banner) { try { const dims = await getImgDims(banner); // Cover-fit: scale image so it fills the banner area without stretching, // cropping whatever overflows (centered crop). const targetW = PAGE_W; const targetH = BANNER_H; const imgRatio = dims.w / dims.h; const targetRatio = targetW / targetH; let drawW, drawH, drawX, drawY; if (imgRatio > targetRatio) { // Image is wider — match height, overflow horizontally drawH = targetH; drawW = drawH * imgRatio; drawX = (targetW - drawW) / 2; drawY = 0; } else { // Image is taller — match width, overflow vertically drawW = targetW; drawH = drawW / imgRatio; drawX = 0; drawY = (targetH - drawH) / 2; } // Clip to the banner area so overflow doesn't show doc.saveGraphicsState(); // jsPDF clipping: draw a clipping rectangle if (typeof doc.rect === "function" && typeof doc.clip === "function") { doc.rect(0, 0, targetW, targetH); doc.clip(); doc.discardPath(); } doc.addImage(banner, dataMime(banner), drawX, drawY, drawW, drawH); doc.restoreGraphicsState(); } catch(e) { console.warn("Banner image failed", e); setFill(brand); doc.rect(0, 0, PAGE_W, BANNER_H, "F"); } } else { setFill(brand); doc.rect(0, 0, PAGE_W, BANNER_H, "F"); } } else { // No banner — use brand colour as a flat hero, with a subtle dark band at bottom setFill(brand); doc.rect(0, 0, PAGE_W, BANNER_H, "F"); } // Team logo — overlapping the banner / white area boundary (sits lower-left) const effectiveLogo = j.logo || (state.teamLogos && state.teamLogos[j.team]) || null; const LOGO_SIZE = 90; const LOGO_X = MARGIN; const LOGO_Y = BANNER_H - 45; // overlaps banner and page body if (effectiveLogo) { const logo = await imgToData(effectiveLogo); if (logo) { try { // White card around logo with a thin brand-colour border for premium feel setFill([255, 255, 255]); doc.rect(LOGO_X, LOGO_Y, LOGO_SIZE, LOGO_SIZE, "F"); setStroke(brand); doc.setLineWidth(1.5); doc.rect(LOGO_X, LOGO_Y, LOGO_SIZE, LOGO_SIZE, "S"); doc.addImage(logo, dataMime(logo), LOGO_X + 6, LOGO_Y + 6, LOGO_SIZE - 12, LOGO_SIZE - 12); } catch(e) { console.warn("Logo failed", e); } } } else { // Initial fallback setFill([255, 255, 255]); doc.rect(LOGO_X, LOGO_Y, LOGO_SIZE, LOGO_SIZE, "F"); setStroke(brand); doc.setLineWidth(1.5); doc.rect(LOGO_X, LOGO_Y, LOGO_SIZE, LOGO_SIZE, "S"); doc.setFont("helvetica", "bold"); doc.setFontSize(56); setText(brand); const initial = (j.team || "?").charAt(0).toUpperCase(); const initW = doc.getTextWidth(initial); doc.text(initial, LOGO_X + LOGO_SIZE / 2 - initW / 2, LOGO_Y + 66); } // Tags row — below the logo, on the white area const tagsY = BANNER_H + 70; doc.setFont("helvetica", "bold"); doc.setFontSize(9); // Team tag — brand colour, white text setFill(brand); const teamText = (j.team || "").toUpperCase(); const teamTagW = doc.getTextWidth(teamText) + 18; doc.rect(MARGIN, tagsY, teamTagW, 22, "F"); setText([255, 255, 255]); doc.text(teamText, MARGIN + 9, tagsY + 15); // Category tag — dark, white text const catText = (j.cat || "").toUpperCase(); const catTagW = doc.getTextWidth(catText) + 18; setFill([20, 20, 20]); doc.rect(MARGIN + teamTagW + 8, tagsY, catTagW, 22, "F"); setText([255, 255, 255]); doc.text(catText, MARGIN + teamTagW + 17, tagsY + 15); // Big title — large, bold, dark doc.setFont("helvetica", "bold"); doc.setFontSize(34); setText([20, 20, 20]); const titleLines = doc.splitTextToSize((j.title || "Untitled brief").toUpperCase(), COL_W); let titleY = tagsY + 55; titleLines.forEach(line => { doc.text(line, MARGIN, titleY); titleY += 38; }); // Subtle horizontal rule setStroke([220, 220, 220]); doc.setLineWidth(0.5); doc.line(MARGIN, titleY + 10, PAGE_W - MARGIN, titleY + 10); // Meta row — clean key/value pairs in three columns const metaRowY = titleY + 35; const metaCol = COL_W / 3; function drawMeta(label, value, colIdx) { const x = MARGIN + metaCol * colIdx; doc.setFont("helvetica", "normal"); doc.setFontSize(8); setText([140, 140, 140]); doc.text(label.toUpperCase(), x, metaRowY); doc.setFont("helvetica", "bold"); doc.setFontSize(13); setText([20, 20, 20]); doc.text(value || "—", x, metaRowY + 18); } drawMeta("Team", j.team || "—", 0); drawMeta("Category", j.cat || "—", 1); drawMeta("Time Budget", j.dur || "—", 2); drawPageFooter(pageNum, 99); // total updated later // ----- CONTENT (starts directly below the cover on page 1) ----- const tabNames = state.tabNames || DEFAULT_TAB_NAMES; // Continue from the bottom of the meta row, leaving some breathing room let y = metaRowY + 50; function newPage() { drawPageFooter(pageNum, 99); doc.addPage(); pageNum++; drawPageHeader(pageNum, 99); y = MARGIN + 20; } function ensureSpace(neededH) { if (y + neededH > PAGE_H - 60) newPage(); } for (let spIdx = 0; spIdx < j.subpages.length; spIdx++) { const sp = j.subpages[spIdx]; const tabName = tabNames[spIdx] || sp.name || `Section ${spIdx + 1}`; // Section header — coloured bar + tab name ensureSpace(50); setFill(brand); doc.rect(MARGIN, y, 8, 30, "F"); doc.setFont("helvetica", "bold"); doc.setFontSize(22); setText([20, 20, 20]); doc.text(tabName.toUpperCase(), MARGIN + 18, y + 22); y += 50; // Iterate rows if (!Array.isArray(sp.rows)) continue; for (const row of sp.rows) { if (!Array.isArray(row.columns)) continue; // Render all blocks across all columns of this row, top-to-bottom, left-to-right. // In PDF we don't try to recreate multi-column layouts perfectly — for readability // and printability we serialize blocks into a vertical flow. const allBlocks = row.columns.flat(); for (const b of allBlocks) { await renderBlockToPdf(b); } y += 8; // gap between rows } } drawPageFooter(pageNum, 99); // ----- Now go back and rewrite page numbers with correct total ----- const totalPages = doc.getNumberOfPages(); for (let p = 1; p <= totalPages; p++) { doc.setPage(p); // Re-draw just the page number portion of the footer with the correct total // Wipe a strip first to avoid double-printing setFill([255, 255, 255]); doc.rect(PAGE_W - MARGIN - 120, PAGE_H - 30, 120, 14, "F"); doc.setFont("helvetica", "normal"); doc.setFontSize(8); setText([120, 120, 120]); const t = `Page ${p} of ${totalPages}`; const tw = doc.getTextWidth(t); doc.text(t, PAGE_W - MARGIN - tw, PAGE_H - 22); } // Filename: TEAM_BRIEFNUM_TITLE const safe = s => (s || "").toString().replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").substring(0, 40); const filename = `${safe(j.team)}_${safe(j.num || "")}_${safe(j.title)}.pdf`.replace(/^_+|_+$/g, ""); doc.save(filename); toast("PDF downloaded"); // ----- Helper: render a single block to the PDF ----- async function renderBlockToPdf(b) { if (!b || !b.type) return; const t = b.type; if (t === "divider") { ensureSpace(20); setStroke([200, 200, 200]); doc.setLineWidth(0.5); doc.line(MARGIN, y + 8, PAGE_W - MARGIN, y + 8); y += 20; return; } if (t === "h1") { const text = (b.content || "").trim() || " "; doc.setFont("helvetica", "bold"); doc.setFontSize(20); setText([20, 20, 20]); const lines = doc.splitTextToSize(text, COL_W); const blockH = lines.length * 26 + 8; ensureSpace(blockH); lines.forEach(line => { doc.text(line, MARGIN, y + 20); y += 26; }); y += 8; return; } if (t === "h2") { const text = (b.content || "").trim() || " "; doc.setFont("helvetica", "bold"); doc.setFontSize(14); setText(brand); const lines = doc.splitTextToSize(text, COL_W); const blockH = lines.length * 20 + 6; ensureSpace(blockH); lines.forEach(line => { doc.text(line, MARGIN, y + 14); y += 20; }); y += 6; return; } if (t === "text") { const text = (b.content || "").trim(); if (!text) return; doc.setFont("helvetica", "normal"); doc.setFontSize(11); setText([40, 40, 40]); const lines = doc.splitTextToSize(text, COL_W); for (const line of lines) { ensureSpace(16); doc.text(line, MARGIN, y + 12); y += 16; } y += 6; return; } if (t === "bullet") { const text = (b.content || "").trim(); if (!text) return; doc.setFont("helvetica", "normal"); doc.setFontSize(11); setText([40, 40, 40]); const lines = doc.splitTextToSize(text, COL_W - 16); const firstLineH = 16; ensureSpace(firstLineH); // Bullet dot setFill(brand); doc.circle(MARGIN + 4, y + 8, 2, "F"); setText([40, 40, 40]); lines.forEach((line, i) => { if (i > 0) ensureSpace(16); doc.text(line, MARGIN + 14, y + 12); y += 16; }); y += 4; return; } if (t === "quote") { const text = (b.content || "").trim(); if (!text) return; doc.setFont("helvetica", "bolditalic"); doc.setFontSize(14); setText([60, 60, 60]); const lines = doc.splitTextToSize(text, COL_W - 16); const blockH = lines.length * 20 + 8; ensureSpace(blockH); // Coloured left bar setFill(brand); doc.rect(MARGIN, y, 3, blockH - 8, "F"); lines.forEach(line => { doc.text(line, MARGIN + 12, y + 16); y += 20; }); y += 8; return; } if (t === "image") { if (!b.content) return; const data = await imgToData(b.content); if (!data) return; try { const dims = await getImgDims(data); // Respect block.width if set (10-100%), default 100% const widthPct = (typeof b.width === "number" && b.width >= 10 && b.width <= 100) ? b.width : 100; const imgW = COL_W * (widthPct / 100); const imgH = (dims.h / dims.w) * imgW; // Honour alignment let imgX = MARGIN; if (b.align === "right") imgX = MARGIN + (COL_W - imgW); else if (b.align !== "left") imgX = MARGIN + (COL_W - imgW) / 2; // center default ensureSpace(imgH + (b.caption ? 24 : 12)); doc.addImage(data, dataMime(data), imgX, y, imgW, imgH); y += imgH + 4; if (b.caption) { doc.setFont("helvetica", "italic"); doc.setFontSize(9); setText([120, 120, 120]); const capLines = doc.splitTextToSize(b.caption, COL_W); capLines.forEach(line => { doc.text(line, MARGIN, y + 10); y += 12; }); } y += 8; } catch(e) { console.warn("Image block failed", e); } return; } if (t === "video") { // Render as a labelled link card (videos can't embed in PDF) ensureSpace(50); setFill([245, 245, 245]); setStroke([200, 200, 200]); doc.setLineWidth(0.5); doc.rect(MARGIN, y, COL_W, 42, "FD"); doc.setFont("helvetica", "bold"); doc.setFontSize(11); setText([20, 20, 20]); doc.text("📹 VIDEO", MARGIN + 12, y + 18); doc.setFont("helvetica", "normal"); doc.setFontSize(9); setText([100, 100, 100]); const url = b.content || "(no link set)"; const urlLines = doc.splitTextToSize(url, COL_W - 24); doc.text(urlLines[0] || "", MARGIN + 12, y + 32); y += 50; return; } if (t === "link") { // Render a link card with optional image preview, label and URL const hasImg = !!b.image; const url = b.content || ""; const label = b.label || url || "Link"; const cardH = hasImg ? 110 : 50; ensureSpace(cardH + 8); const startY = y; setFill([248, 248, 248]); setStroke([200, 200, 200]); doc.setLineWidth(0.5); doc.rect(MARGIN, y, COL_W, cardH, "FD"); if (hasImg) { const data = await imgToData(b.image); if (data) { try { const imgH = 70; const imgW = 100; doc.addImage(data, dataMime(data), MARGIN + 8, y + 8, imgW, imgH); } catch(e) {} } } const textX = hasImg ? MARGIN + 120 : MARGIN + 12; doc.setFont("helvetica", "bold"); doc.setFontSize(12); setText([20, 20, 20]); const labelLines = doc.splitTextToSize(label, COL_W - (hasImg ? 130 : 24)); doc.text(labelLines[0] || "", textX, y + 22); doc.setFont("helvetica", "normal"); doc.setFontSize(9); setText(brand); const urlLines = doc.splitTextToSize(url, COL_W - (hasImg ? 130 : 24)); doc.text(urlLines[0] || "", textX, y + 38); // Make the entire card clickable if (url) { doc.link(MARGIN, y, COL_W, cardH, { url: ensureLinkUrl(url) }); } y = startY + cardH + 8; return; } } } catch (err) { console.error("PDF export failed:", err); toast("PDF export failed — check console", true); } } // Convert a hex colour like #4fffa2 to [r,g,b] function hexToRgb(hex) { if (!hex) return null; hex = hex.replace("#", ""); if (hex.length === 3) hex = hex.split("").map(c => c+c).join(""); if (hex.length !== 6) return null; return [ parseInt(hex.substr(0,2), 16), parseInt(hex.substr(2,2), 16), parseInt(hex.substr(4,2), 16) ]; } function downloadAsWebsite() { if (document.activeElement && document.activeElement.blur) { document.activeElement.blur(); } clearTimeout(saveTimer); setTimeout(() => { buildWebsiteHtml((html) => { const blob = new Blob([html], { type: "text/html;charset=utf-8" }); downloadBlob(blob, `brief-gg-website-${new Date().toISOString().slice(0,10)}.html`); toast(`Downloaded · ${state.jobs.length} briefs`); }); }, 60); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } // ============ SELF-CONTAINED ZIP WRITER ============ // A tiny ZIP builder using the STORE method (no compression). This removes the // dependency on the JSZip CDN, so Flatten / Export work fully OFFLINE. // Images (JPG/PNG/WEBP) are already compressed, so storing them uncompressed // costs almost nothing in size. Mirrors the small slice of JSZip's API we use: // z.file(name, str) z.file(name, b64, {base64:true}) // z.folder("images").file(...) await z.generateAsync({type:"blob"}) const MiniZip = (function () { let crcTable = null; function buildCrc() { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; } function crc32(bytes) { if (!crcTable) crcTable = buildCrc(); let crc = 0xFFFFFFFF; for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ crcTable[(crc ^ bytes[i]) & 0xFF]; return (crc ^ 0xFFFFFFFF) >>> 0; } const enc = new TextEncoder(); function b64ToBytes(b64) { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function Zip() { this.files = []; } Zip.prototype.file = function (name, content, opts) { let bytes; if (opts && opts.base64) bytes = b64ToBytes(content); else if (content instanceof Uint8Array) bytes = content; else bytes = enc.encode(String(content)); this.files.push({ name: name, bytes: bytes }); return this; }; Zip.prototype.folder = function (folderName) { const self = this; const prefix = folderName.replace(/\/+$/, "") + "/"; return { file: function (name, content, opts) { self.file(prefix + name, content, opts); return this; } }; }; Zip.prototype.generateAsync = function () { const parts = []; const central = []; let offset = 0; const u16 = (a, v) => a.push(v & 0xFF, (v >>> 8) & 0xFF); const u32 = (a, v) => a.push(v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF); for (const f of this.files) { const nameBytes = enc.encode(f.name); const data = f.bytes; const crc = crc32(data); const size = data.length; const local = []; u32(local, 0x04034b50); u16(local, 20); u16(local, 0x0800); u16(local, 0); u16(local, 0); u16(local, 0); u32(local, crc); u32(local, size); u32(local, size); u16(local, nameBytes.length); u16(local, 0); const localHeader = new Uint8Array(local); parts.push(localHeader, nameBytes, data); const localOffset = offset; offset += localHeader.length + nameBytes.length + data.length; const cen = []; u32(cen, 0x02014b50); u16(cen, 20); u16(cen, 20); u16(cen, 0x0800); u16(cen, 0); u16(cen, 0); u16(cen, 0); u32(cen, crc); u32(cen, size); u32(cen, size); u16(cen, nameBytes.length); u16(cen, 0); u16(cen, 0); u16(cen, 0); u16(cen, 0); u32(cen, 0); u32(cen, localOffset); central.push(new Uint8Array(cen), nameBytes); } const centralStart = offset; let centralSize = 0; for (const c of central) centralSize += c.length; const end = []; u32(end, 0x06054b50); u16(end, 0); u16(end, 0); u16(end, this.files.length); u16(end, this.files.length); u32(end, centralSize); u32(end, centralStart); u16(end, 0); const allParts = parts.concat(central, [new Uint8Array(end)]); return Promise.resolve(new Blob(allParts, { type: "application/zip" })); }; return { create: function () { return new Zip(); } }; })(); // ============ ZIP EXPORT ============ // Build a ZIP containing: brief-gg.html + images/ folder // All embedded base64 images are extracted to separate files, HTML references them by relative path. // Result: dramatically smaller HTML, all images viewable separately, fully offline-portable when unzipped. async function downloadAsZip() { // Uses the self-contained MiniZip writer — no CDN, works offline. if (document.activeElement && document.activeElement.blur) { document.activeElement.blur(); } clearTimeout(saveTimer); await new Promise(r => setTimeout(r, 60)); try { // STEP 1: Walk the state, extract images, build a manifest // We build a parallel state with image refs swapped to file paths. const imageFiles = {}; // path -> { mime, base64 } const seenImages = {}; // base64 -> path (dedup identical images) let imgCounter = 0; function extractDataUrl(dataUrl) { if (!dataUrl || typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return null; const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!m) return null; return { mime: m[1], base64: m[2] }; } function mimeToExt(mime) { if (mime === "image/jpeg") return "jpg"; if (mime === "image/png") return "png"; if (mime === "image/gif") return "gif"; if (mime === "image/webp") return "webp"; if (mime === "image/svg+xml") return "svg"; return "bin"; } function saveImage(dataUrl, hint) { const parsed = extractDataUrl(dataUrl); if (!parsed) return dataUrl; // not a data URL, leave alone // Reuse the same file for identical image data (job.html repeats its logo/ // banner many times — dedup keeps the images/ folder lean). if (seenImages[parsed.base64]) return seenImages[parsed.base64]; imgCounter++; const ext = mimeToExt(parsed.mime); const safeName = (hint || "img").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").substring(0, 40); const path = `images/${String(imgCounter).padStart(4, "0")}-${safeName}.${ext}`; imageFiles[path] = { mime: parsed.mime, base64: parsed.base64 }; seenImages[parsed.base64] = path; return path; } // Deep clone state so we don't mutate the editor's working state const exportState = JSON.parse(JSON.stringify(state)); // Walk: team logos if (exportState.teamLogos) { Object.keys(exportState.teamLogos).forEach(team => { exportState.teamLogos[team] = saveImage(exportState.teamLogos[team], `team-${team}`); }); } // Walk: jobs exportState.jobs.forEach(job => { if (job.banner) job.banner = saveImage(job.banner, `${job.team}-banner`); if (job.logo) job.logo = saveImage(job.logo, `${job.team}-logo`); if (Array.isArray(job.subpages)) { job.subpages.forEach(sp => { if (Array.isArray(sp.rows)) { sp.rows.forEach(row => { if (Array.isArray(row.columns)) { row.columns.forEach(col => { col.forEach(block => { if (block.type === "image" && block.content) { block.content = saveImage(block.content, `${job.team}-block`); } if (block.type === "link" && block.image) { block.image = saveImage(block.image, `${job.team}-link`); } }); }); } }); } }); } }); // STEP 2: Build HTML with the slimmed-down state (paths instead of base64) // Force home view first, identical to the regular website export const prevView = view; const prevJobId = state.currentJobId; const prevSubpage = state.currentSubpage; // Swap the live state to the PATH-BASED export data before snapshotting the // DOM, so the rendered job-card tags reference images/… files instead // of carrying base64 data URLs (which would bloat the exported HTML). const realJobs = state.jobs; const realTeamLogos = state.teamLogos; state.jobs = exportState.jobs; state.teamLogos = exportState.teamLogos || {}; try { state.currentJobId = null; state.currentSubpage = 0; goHome(); } catch (e) { /* ignore */ } await new Promise(r => setTimeout(r, 80)); let html = "\n" + document.documentElement.outerHTML; // Restore the real (base64) data immediately so the editor is unaffected. state.jobs = realJobs; state.teamLogos = realTeamLogos; // Belt-and-suspenders: strip any base64 image data URLs that slipped into // the snapshot markup (the file re-renders from embedded paths on load). html = html.replace(/src="data:image\/[^"]*"/g, 'src=""'); // Strip transient body attributes from the snapshot so the export always // boots clean regardless of what theme/zoom/a11y the editor was in. html = html.replace(/ ]*>/, ' '); // Use a slimmed export state with paths const embeddedState = { jobs: exportState.jobs, currentJobId: null, currentSubpage: 0, activeCat: "All", activeTeam: "All", a11y: { fs: 1, motion: false }, tabNames: exportState.tabNames || [...DEFAULT_TAB_NAMES], teamLogos: exportState.teamLogos || {}, teamColors: exportState.teamColors || {} }; const jsonStr = JSON.stringify(embeddedState); const dataB64 = btoa(unescape(encodeURIComponent(jsonStr))); const SCRIPT_OPEN = ''; const SCRIPT_CLOSE = ''; const SCRIPT_OCTET = ''; const injection = SCRIPT_OCTET + dataB64 + SCRIPT_CLOSE + '\n' + SCRIPT_OPEN + '(function(){' + 'try{' + 'var el=document.getElementById("brief-gg-data");' + 'if(!el)return;' + 'var json=decodeURIComponent(escape(atob(el.textContent.trim())));' + 'window.__BRIEF_GG_DATA__=JSON.parse(json);' + 'window.__BRIEF_GG_EXPORTED__=true;' + '}catch(e){console.error("Embedded data load failed",e);}' + '})();' + 'window.addEventListener("DOMContentLoaded",function(){' + 'function hideAdmin(){' + 'var ids=["unlock-btn","lock-btn"];' + 'for(var i=0;i", injection + ""); // Restore editor view if (prevJobId && prevView === "job") { state.currentJobId = prevJobId; state.currentSubpage = prevSubpage; openJob(prevJobId); } else if (prevView === "jobs") { goJobs(); } // STEP 3: Build the ZIP const zip = MiniZip.create(); // Belt-and-braces: strip any base64 still embedded in the board html itself // (e.g. the injectBoardData constants) into the images/ folder too, so the // final index.html carries zero base64. html = html.replace( /data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+/g, (dataUrl) => saveImage(dataUrl, "board") ); zip.file("index.html", html); // Bundle any locally-referenced brief HTML files (e.g. the Team Liquid card // links to job.html). Fetch each while the site is still served, then FLATTEN // it: pull every base64 data URL out into the shared images/ folder and swap // in a relative path, so the bundled brief carries zero base64 either. Also // force it to boot read-only (non-editable) via the __BRIEF_GG_FLAT__ flag. const localHtmlFiles = [...new Set( (state.jobs || []) .map(j => j.fileUrl) .filter(u => u && /^[^:]+\.html($|[?#])/i.test(u) && !u.startsWith("http")) .map(u => u.split(/[?#]/)[0]) )]; let bundledBriefFiles = 0; for (const path of localHtmlFiles) { try { const resp = await fetch(path, { cache: "no-store" }); if (!resp || !resp.ok) continue; let text = await resp.text(); // Extract every base64 image into the images/ folder, replace with path. text = text.replace( /data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+/g, (dataUrl) => saveImage(dataUrl, "brief") ); // Force the flattened brief to boot locked / read-only. if (!/__BRIEF_GG_FLAT__/.test(text.slice(0, 4000))) { const flatTag = 'window.__BRIEF_GG_FLAT__=true;'; text = text.replace(/]*)>/i, '\n' + flatTag); } // The bundled file's own injected content uses hardcoded defaults — // pull this brief's LIVE saved customizations (accent colour etc.) from // its own storage slot and inject an override so the flattened copy // reflects what you actually see in your browser, not just the source // defaults. try { const ownKey = "brief-gg-v4::" + path.toLowerCase(); const ownRaw = await readFullValue(ownKey, tryArtifactGet) || await readFullValue(ownKey, idbGet) || await readFullValue(ownKey, (k) => tryLocalGet(k)); if (ownRaw) { const ownState = JSON.parse(ownRaw); const ownJob = (ownState.jobs || []).find(j => (j._injectionId && j._injectionId.startsWith("tl-lol-")) || (j.title && j.title.includes("Team Liquid")) ); if (ownJob && (ownJob.accent || ownJob.accentFg)) { const overrideTag = '(function(){' + 'var OV=' + JSON.stringify({ accent: ownJob.accent, accentFg: ownJob.accentFg }) + ';' + 'function tryApply(){' + 'if(typeof state==="undefined"||!state.jobs){setTimeout(tryApply,50);return;}' + 'var job=state.jobs.find(function(j){return j.title&&j.title.indexOf("Team Liquid")!==-1;});' + 'if(!job){setTimeout(tryApply,50);return;}' + 'if(OV.accent)job.accent=OV.accent;' + 'if(OV.accentFg)job.accentFg=OV.accentFg;' + 'if(typeof renderJobView==="function")renderJobView();' + 'if(typeof saveNow==="function")saveNow();' + '}' + 'tryApply();' + '})();'; text = text.replace(/<\/body>/i, overrideTag + '\n'); } } } catch (e) { console.warn("Could not carry over live customizations for", path, e); } zip.file(path, text); bundledBriefFiles++; } catch (e) { console.warn("Could not bundle brief file:", path, e); } } // Write all extracted images (from the board AND the bundled briefs) — after // brief flattening so their images are included too. Object.keys(imageFiles).forEach(path => { zip.file(path, imageFiles[path].base64, { base64: true }); }); // Add a small README to help end users const readme = `BRIEF.GG — exported site ============================== This folder contains your finished site: index.html — open this file in any web browser to view the site images/ — folder containing all images used in the site HOW TO USE: 1. Keep "index.html" and the "images" folder together in the SAME folder 2. Double-click "index.html" to open the site in your default browser (or right-click → Open with → pick Chrome/Firefox/Safari) 3. The site works offline — no internet connection needed HOW TO SHARE: - Re-zip the entire folder (both index.html AND images/) and send the ZIP - Or upload the unzipped folder to a hosting service (Netlify, GitHub Pages) Generated ${new Date().toISOString().slice(0,10)} · ${exportState.jobs.length} briefs · ${imgCounter} images `; zip.file("README.txt", readme); // STEP 4: Generate and download const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); const now = new Date().toISOString().slice(0, 10); downloadBlob(blob, `brief-gg-website-${now}.zip`); const sizeMB = (blob.size / 1024 / 1024).toFixed(1); toast(`ZIP downloaded · ${imgCounter} images · ${bundledBriefFiles} brief page${bundledBriefFiles===1?'':'s'} · ${sizeMB} MB · unzip and open index.html`); } catch (err) { console.error("ZIP export failed:", err); toast("ZIP export failed — check console", true); } } function buildWebsiteHtml(callback) { // Force home view first so the embedded site opens cleanly const prevView = view; const prevJobId = state.currentJobId; const prevSubpage = state.currentSubpage; try { state.currentJobId = null; state.currentSubpage = 0; state.activeCat = "All"; state.activeTeam = "All"; goHome(); } catch (e) { console.warn("prep-home failed", e); } setTimeout(() => { try { // SIMPLE: take the live HTML as a string, no DOM cloning that risks // re-serialization breakage. Inject the data + boot script before . let html = "\n" + document.documentElement.outerHTML; // Strip transient body attributes from the snapshot so the export always // boots clean regardless of what theme/zoom/a11y the editor was in. html = html.replace(/ ]*>/, ' '); // Build data payload in base64 (no escaping issues) const embeddedState = { jobs: state.jobs, currentJobId: null, currentSubpage: 0, activeCat: "All", activeTeam: "All", a11y: { fs: 1, motion: false }, tabNames: state.tabNames || [...DEFAULT_TAB_NAMES], teamLogos: state.teamLogos || {}, teamColors: state.teamColors || {} }; const jsonStr = JSON.stringify(embeddedState); const dataB64 = btoa(unescape(encodeURIComponent(jsonStr))); // ONE concatenated injection — using string concatenation to avoid // any literal closing-script-tag string appearing in this source code. const SCRIPT_OPEN = ''; const SCRIPT_CLOSE = ''; const SCRIPT_OCTET = ''; const injection = SCRIPT_OCTET + dataB64 + SCRIPT_CLOSE + '\n' + SCRIPT_OPEN + '(function(){' + 'try{' + 'var el=document.getElementById("brief-gg-data");' + 'if(!el)return;' + 'var json=decodeURIComponent(escape(atob(el.textContent.trim())));' + 'window.__BRIEF_GG_DATA__=JSON.parse(json);' + 'window.__BRIEF_GG_EXPORTED__=true;' + '}catch(e){console.error("Embedded data load failed",e);}' + '})();' + 'window.addEventListener("DOMContentLoaded",function(){' + 'function hideAdmin(){' + 'var ids=["unlock-btn","lock-btn"];' + 'for(var i=0;i — single, predictable replace html = html.replace("", injection + ""); // Restore the editor view if (prevJobId && prevView === "job") { state.currentJobId = prevJobId; state.currentSubpage = prevSubpage; openJob(prevJobId); } else if (prevView === "jobs") { goJobs(); } callback(html); } catch (err) { console.error("Build failed:", err); toast("Export failed — check console", true); } }, 80); } // Wipe localStorage and re-seed from the embedded snapshot in this file. // Useful when opening a newer version of the file on a device that has old data cached. async function resetToFile() { if (!window.__BRIEF_GG_EDITABLE_BACKUP__ || !window.__BRIEF_GG_DATA__) { toast("No embedded snapshot found — use Reset to defaults instead", true); return; } if (!confirm("Reset to the data embedded in this file?\n\nThis clears any edits saved on this device and loads fresh from the file. Cannot be undone.")) return; // Clear all storage keys for this app try { // Clear localStorage chunks const keys = Object.keys(localStorage).filter(k => k.startsWith(KEY)); keys.forEach(k => localStorage.removeItem(k)); } catch(e) { /* ignore */ } try { // Clear artifact storage if available if (window.storage && window.storage.list) { const listed = await window.storage.list(KEY); if (listed && listed.keys) { for (const k of listed.keys) await window.storage.delete(k); } } } catch(e) { /* ignore */ } // Re-seed from embedded snapshot state = { ...state, ...window.__BRIEF_GG_DATA__ }; migrateState(state); isDirty = false; editCount = 0; hasEverEdited = false; await saveNow(); applyA11y(); updateSaveStatus(); goHome(); toast("Reset to file — loaded fresh from this file"); } async function resetAll() { if (!confirm("Reset everything to the default briefs? Your custom edits will be lost.")) return; state = { jobs: seedJobs(), currentJobId: null, currentSubpage: 0, activeCat: "All", activeTeam: "All", a11y: state.a11y, passwordHash: state.passwordHash, tabNames: [...DEFAULT_TAB_NAMES], teamLogos: state.teamLogos || {}, // keep team logo library across reset teamColors: state.teamColors || {} // keep team colours across reset }; isDirty = false; editCount = 0; hasEverEdited = false; await saveNow(); updateSaveStatus(); goHome(); toast("Reset to defaults (team logos and colours kept)"); } // ============ APPLY LAYOUT (copy structure from another brief) ============ const PLACEHOLDER_TEXT = { h1: "REPLACE: Heading", h2: "REPLACE: Subheading", text: "REPLACE: Add your text here", bullet: "REPLACE: List item", quote: "REPLACE: Add a quote", video: "", // empty URL — placeholder UI shows image: "", // empty data URL — placeholder UI shows divider: "" }; function openApplyLayoutModal() { const j = currentJob(); if (!j) return; // Populate source brief dropdown (excluding current brief) const briefSelect = document.getElementById("apply-source-brief"); briefSelect.innerHTML = ""; state.jobs.filter(other => other.id !== j.id).forEach(other => { const opt = document.createElement("option"); opt.value = other.id; opt.textContent = `${other.team} — ${other.title}`; briefSelect.appendChild(opt); }); if (!briefSelect.options.length) { toast("No other briefs to copy from", true); return; } // Populate target name const tabName = state.tabNames[state.currentSubpage] || `Page ${state.currentSubpage+1}`; document.getElementById("apply-target-name").textContent = `the "${tabName}" subpage`; // Populate source subpage dropdown based on first source brief populateSourceSubpages(); briefSelect.onchange = populateSourceSubpages; document.getElementById("modal-apply-layout").classList.add("open"); } function populateSourceSubpages() { const subSelect = document.getElementById("apply-source-subpage"); subSelect.innerHTML = ""; // Use state.tabNames since they are global state.tabNames.forEach((name, i) => { const opt = document.createElement("option"); opt.value = i; opt.textContent = `0${i+1} · ${name}`; if (i === state.currentSubpage) opt.selected = true; subSelect.appendChild(opt); }); } function closeApplyLayoutModal() { document.getElementById("modal-apply-layout").classList.remove("open"); } // Deep clone a subpage's structure but replace content with placeholders function clonePagesAsPlaceholder(sourceSp) { const cloned = JSON.parse(JSON.stringify(sourceSp)); // Strip the name (we use global names), regenerate IDs, replace content delete cloned.name; if (Array.isArray(cloned.rows)) { cloned.rows.forEach(row => { row.id = uid(); if (Array.isArray(row.columns)) { row.columns.forEach(col => { col.forEach(b => { b.id = uid(); // Replace content with placeholder if (b.type in PLACEHOLDER_TEXT) { b.content = PLACEHOLDER_TEXT[b.type]; } else { b.content = ""; } // Strip captions on images if (b.caption) b.caption = ""; }); }); } }); } return cloned; } async function applyLayoutFromSource() { const sourceBriefId = document.getElementById("apply-source-brief").value; const sourceSubpageIdx = parseInt(document.getElementById("apply-source-subpage").value); const scope = document.getElementById("apply-scope").value; const sourceJob = state.jobs.find(j => j.id === sourceBriefId); const targetJob = currentJob(); if (!sourceJob || !targetJob) { toast("Couldn't find brief", true); return; } const sourceSp = sourceJob.subpages[sourceSubpageIdx]; if (!sourceSp) { toast("Source subpage not found", true); return; } const willOverwrite = scope === "current" ? `the "${state.tabNames[state.currentSubpage]}" subpage` : `ALL ${targetJob.subpages.length} subpages of this brief`; if (!confirm( `Copy layout from "${sourceJob.title}" to ${willOverwrite}?\n\n` + `Existing content will be REPLACED with placeholder text/empty images.\n` + `This cannot be undone (but you can ↑ Load a backup if needed).` )) return; if (scope === "current") { // Apply to current subpage only const placeholder = clonePagesAsPlaceholder(sourceSp); targetJob.subpages[state.currentSubpage].rows = placeholder.rows; } else { // Apply same source layout to ALL subpages of this brief targetJob.subpages.forEach((sp, i) => { const placeholder = clonePagesAsPlaceholder(sourceSp); sp.rows = placeholder.rows; }); } saveNow(); renderCanvas(); closeApplyLayoutModal(); toast("Layout applied — replace placeholders with your content"); } function openTeamLogosModal() { renderTeamLogosList(); document.getElementById("modal-team-logos").classList.add("open"); } function closeTeamLogosModal() { document.getElementById("modal-team-logos").classList.remove("open"); // Refresh the jobs list in case logos changed if (view === "jobs") renderJobs(); } function renderTeamLogosList() { const list = document.getElementById("team-logos-list"); list.innerHTML = ""; // Count briefs per team, sort alphabetically const counts = {}; state.jobs.forEach(j => { if (j.team) counts[j.team] = (counts[j.team] || 0) + 1; }); const teams = Object.keys(counts).sort(); if (!teams.length) { list.innerHTML = `
No teams yet — create a brief first
`; return; } teams.forEach(team => { const row = document.createElement("div"); row.className = "tl-row"; const logo = (state.teamLogos && state.teamLogos[team]) || null; const initial = team.trim().charAt(0).toUpperCase(); const currentColor = (state.teamColors && state.teamColors[team]) || TEAM_COLORS[team] || "#666666"; row.innerHTML = `
${escapeHtml(team)}
${counts[team]} brief${counts[team] !== 1 ? 's' : ''}
Colour
${logo ? `` : ''}
`; const openUpload = () => { pendingTeamLogoFor = team; pendingImageBlockId = null; pendingJobMediaId = null; pendingJobMediaField = null; document.getElementById("file-input").click(); }; row.querySelector("[data-action='upload']").onclick = openUpload; // Also allow clicking the preview itself to upload const preview = row.querySelector(".tl-preview"); preview.style.cursor = "pointer"; preview.onclick = openUpload; // Drag-and-drop onto the preview preview.addEventListener("dragover", e => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); preview.style.borderColor = "var(--mint)"; preview.style.boxShadow = "0 0 0 3px rgba(79,255,162,0.15)"; } }); preview.addEventListener("dragleave", () => { preview.style.borderColor = ""; preview.style.boxShadow = ""; }); preview.addEventListener("drop", e => { if (!e.dataTransfer.files.length) return; e.preventDefault(); preview.style.borderColor = ""; preview.style.boxShadow = ""; handleTeamLogoFile(e.dataTransfer.files[0], team); }); const removeBtn = row.querySelector("[data-action='remove']"); if (removeBtn) removeBtn.onclick = async () => { delete state.teamLogos[team]; await saveNow(); renderTeamLogosList(); }; // Colour picker const colorInput = row.querySelector(".tl-color-input"); if (colorInput) { colorInput.addEventListener("input", () => { if (!state.teamColors) state.teamColors = {}; state.teamColors[team] = colorInput.value; // Live-update the preview border and initial letter colour const preview = row.querySelector(".tl-preview"); if (preview) { preview.style.borderColor = colorInput.value; const initial = preview.querySelector("span"); if (initial) initial.style.color = colorInput.value; } saveLater(); // Refresh chips and cards so colour changes are visible immediately if (view === "jobs") renderJobs(); else if (view === "home") renderHome(); }); } list.appendChild(row); }); } async function handleTeamLogoFile(file, team) { if (!file.type.startsWith("image/")) { toast("Not an image", true); return; } try { const dataUrl = await resizeImageTo(file, 160); if (!state.teamLogos) state.teamLogos = {}; state.teamLogos[team] = dataUrl; await saveNow(); renderTeamLogosList(); toast(`Logo set for ${team}`); } catch (err) { console.error(err); toast("Could not load image", true); } } // ============ PASSWORD / MODE ============ // Simple hash for password storage (not for real security, just obfuscation) async function hashPassword(pw) { const enc = new TextEncoder().encode(pw + "brief-gg-salt-v1"); if (crypto.subtle) { const buf = await crypto.subtle.digest("SHA-256", enc); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,"0")).join(""); } // Fallback simple hash let h = 0; for (let i = 0; i < pw.length; i++) h = ((h << 5) - h + pw.charCodeAt(i)) | 0; return String(h); } function openPasswordModal() { const hasPassword = !!state.passwordHash; // No password set yet → edit mode is open; just unlock directly (no wall). // A password only gates editing if the admin has explicitly set one. if (!hasPassword) { unlockMode(); return; } document.getElementById("pw-enter-view").style.display = hasPassword ? "" : "none"; document.getElementById("pw-set-view").style.display = hasPassword ? "none" : ""; document.getElementById("pw-modal-title").textContent = hasPassword ? "ENTER EDIT MODE" : "SET ADMIN PASSWORD"; document.getElementById("pw-hint").textContent = hasPassword ? "First time? Password was set by the admin." : ""; document.getElementById("pw-input").value = ""; document.getElementById("pw-new").value = ""; document.getElementById("pw-confirm").value = ""; document.getElementById("modal-password").classList.add("open"); setTimeout(() => { const el = document.getElementById(hasPassword ? "pw-input" : "pw-new"); if (el) el.focus(); }, 50); } function closePasswordModal() { document.getElementById("modal-password").classList.remove("open"); } async function setPassword() { const pw = document.getElementById("pw-new").value.trim(); const confirm = document.getElementById("pw-confirm").value.trim(); if (!pw || pw.length < 3) { toast("Password must be at least 3 characters", true); return; } if (pw !== confirm) { toast("Passwords don't match", true); return; } try { state.passwordHash = await hashPassword(pw); await saveNow(); closePasswordModal(); unlockMode(); toast("Password set — you're in edit mode"); } catch (e) { console.error("setPassword error:", e); toast("Could not save password", true); } } async function submitPassword() { const pw = document.getElementById("pw-input").value.trim(); if (!pw) { toast("Enter a password", true); return; } try { const hash = await hashPassword(pw); if (hash !== state.passwordHash) { toast("Wrong password — try again", true); document.getElementById("pw-input").value = ""; document.getElementById("pw-input").focus(); return; } closePasswordModal(); unlockMode(); toast("Edit mode unlocked"); } catch (e) { console.error("submitPassword error:", e); toast("Password check failed", true); } } function resetPassword() { if (!confirm("Forgot your password? This will CLEAR the password so you can set a new one. Continue?")) return; state.passwordHash = null; saveNow(); closePasswordModal(); toast("Password cleared — tap Edit mode to set a new one"); } function unlockMode() { isAdminMode = true; document.body.classList.remove("readonly"); document.body.classList.add("editing"); { const u = document.getElementById("unlock-btn"); if (u) u.style.display = "none"; } { const l = document.getElementById("lock-btn"); if (l) l.style.display = ""; } if (view === "jobs") renderJobs(); else if (view === "job") renderJobView(); } async function lockMode(silent) { isAdminMode = false; document.body.classList.add("readonly"); document.body.classList.remove("editing"); { const u = document.getElementById("unlock-btn"); if (u) u.style.display = ""; } { const l = document.getElementById("lock-btn"); if (l) l.style.display = "none"; } await saveNow(); if (view === "jobs") renderJobs(); else if (view === "job") renderJobView(); if (!silent) toast("Locked — student mode"); } // Import data from previously exported JSON document.getElementById("import-input").addEventListener("change", async e => { const file = e.target.files[0]; e.target.value = ""; if (!file) return; if (!confirm("Import this file? It will REPLACE all current briefs and edits.")) return; try { const text = await file.text(); let data; // Detect format: JSON or HTML const isHtml = file.name.toLowerCase().endsWith(".html") || file.name.toLowerCase().endsWith(".htm") || text.trim().startsWith("]*id=["']brief-gg-data["'][^>]*>([\s\S]*?)<\/script>/i); if (tagMatch) { const inner = tagMatch[1].trim(); // Try base64 first try { const decoded = decodeURIComponent(escape(atob(inner))); // If atob succeeded and result starts with { it's base64-encoded JSON if (decoded.trim().startsWith("{")) { jsonText = decoded; } } catch(e) { // Not base64 — fall through to plain JSON } if (!jsonText) { jsonText = inner; } } else { const oldMatch = text.match(/window\.__BRIEF_GG_DATA__\s*=\s*(\{[\s\S]*?\});/); if (oldMatch) jsonText = oldMatch[1]; } if (!jsonText) throw new Error("No embedded data found in HTML file"); data = JSON.parse(jsonText); } else { // JSON file (legacy backups) data = JSON.parse(text); } if (!data.jobs || !Array.isArray(data.jobs)) throw new Error("Invalid file format"); state = { jobs: data.jobs, currentJobId: null, currentSubpage: 0, activeCat: data.activeCat || "All", activeTeam: data.activeTeam || "All", a11y: data.a11y || state.a11y, passwordHash: data.passwordHash || state.passwordHash, // restore password if backup had one tabNames: data.tabNames || [...DEFAULT_TAB_NAMES], teamLogos: data.teamLogos || {}, teamColors: data.teamColors || {} }; migrateState(state); await saveNow(); applyA11y(); isDirty = false; editCount = 0; hasEverEdited = true; updateSaveStatus(); document.getElementById("welcome-banner").classList.remove("show"); goHome(); toast(`Loaded ${data.jobs.length} briefs from ${isHtml ? 'HTML' : 'JSON'} file`); } catch (err) { console.error(err); toast("Load failed — invalid file", true); } }); // Helper: extract data from a backup file (HTML or JSON), shared by Load + Merge async function readBackupFile(file) { const text = await file.text(); const isHtml = file.name.toLowerCase().endsWith(".html") || file.name.toLowerCase().endsWith(".htm") || text.trim().startsWith("]*id=["']brief-gg-data["'][^>]*>([\s\S]*?)<\/script>/i); if (tagMatch) { const inner = tagMatch[1].trim(); try { const decoded = decodeURIComponent(escape(atob(inner))); if (decoded.trim().startsWith("{")) jsonText = decoded; } catch(e) { /* not base64 */ } if (!jsonText) jsonText = inner; } else { const oldMatch = text.match(/window\.__BRIEF_GG_DATA__\s*=\s*(\{[\s\S]*?\});/); if (oldMatch) jsonText = oldMatch[1]; } } else { jsonText = text; } if (!jsonText) throw new Error("No data found in file"); return JSON.parse(jsonText); } document.getElementById("merge-input").addEventListener("change", async e => { const file = e.target.files[0]; e.target.value = ""; if (!file) return; try { const data = await readBackupFile(file); if (!data.jobs || !Array.isArray(data.jobs)) throw new Error("Invalid file format"); const incomingCount = data.jobs.length; const incomingTeams = Object.keys(data.teamLogos || {}); if (!confirm( `Merge ${incomingCount} brief${incomingCount===1?'':'s'} from this file?\n\n` + `• Briefs will be ADDED to your existing ${state.jobs.length}\n` + `• Team logos will be merged (existing logos kept)\n` + `• Tab names from this file will be IGNORED (current names kept)\n` + `• Password kept (current password unchanged)` )) return; // Generate fresh IDs for incoming jobs to avoid clashes with existing ones const newJobs = data.jobs.map(j => { const cloned = JSON.parse(JSON.stringify(j)); cloned.id = uid(); // Regenerate IDs throughout subpages/rows/blocks if (Array.isArray(cloned.subpages)) { cloned.subpages.forEach(sp => { if (Array.isArray(sp.rows)) { sp.rows.forEach(row => { row.id = uid(); if (Array.isArray(row.columns)) { row.columns.forEach(col => { col.forEach(b => { b.id = uid(); }); }); } }); } }); } return cloned; }); // Append incoming jobs state.jobs = state.jobs.concat(newJobs); // Merge team logos: keep existing, add only missing teams if (data.teamLogos) { if (!state.teamLogos) state.teamLogos = {}; Object.keys(data.teamLogos).forEach(team => { if (!state.teamLogos[team]) { state.teamLogos[team] = data.teamLogos[team]; } }); } // Merge team colours: keep existing, add only missing teams if (data.teamColors) { if (!state.teamColors) state.teamColors = {}; Object.keys(data.teamColors).forEach(team => { if (!state.teamColors[team]) { state.teamColors[team] = data.teamColors[team]; } }); } renumberBriefs(); migrateState(state); await saveNow(); isDirty = false; editCount = 0; hasEverEdited = true; updateSaveStatus(); renderJobs(); goJobs(); toast(`Merged · added ${incomingCount} brief${incomingCount===1?'':'s'} · total ${state.jobs.length}`); } catch (err) { console.error(err); toast("Merge failed — invalid file", true); } }); // ============ ACCESSIBILITY ============ // State schema (defaults filled in by mergeDefaults below): // state.a11y = { // fs: 1, line: 1.6, letter: 0, // font: "default", theme: "dark", colorblind: "none", // focus: false, hideImages: false, ruler: false, tts: false, motion: false // } const A11Y_DEFAULTS = { fs: 1, line: 1.6, letter: 0, font: "default", theme: "dark", colorblind: "none", focus: false, hideImages: false, ruler: false, tts: false, motion: false }; function mergeA11yDefaults() { if (!state.a11y || typeof state.a11y !== "object") state.a11y = {}; Object.keys(A11Y_DEFAULTS).forEach(k => { if (state.a11y[k] === undefined) state.a11y[k] = A11Y_DEFAULTS[k]; }); } function openA11y() { document.getElementById("modal-a11y").classList.add("open"); // Refresh active states each time we open applyA11y(); } function closeA11y() { document.getElementById("modal-a11y").classList.remove("open"); } // Keep old name working in case any other code refers to it function toggleA11y() { openA11y(); } function applyA11y() { mergeA11yDefaults(); const a = state.a11y; // ----- Font size: real page zoom ----- if (CSS && CSS.supports && CSS.supports("zoom: 1")) { document.body.style.zoom = a.fs; document.body.style.transform = ""; document.body.style.width = ""; } else if (!navigator.userAgent.includes("iPhone") && !navigator.userAgent.includes("iPad")) { // Firefox fallback (not on iOS — transform+width breaks mobile layout) document.body.style.zoom = ""; document.body.style.transform = `scale(${a.fs})`; document.body.style.transformOrigin = "0 0"; document.body.style.width = `${(100 / a.fs).toFixed(2)}%`; } else { // iOS: clear any previously set transform styles document.body.style.zoom = ""; document.body.style.transform = ""; document.body.style.width = ""; } document.documentElement.style.setProperty("--fs-mult", a.fs); // ----- Line spacing ----- document.body.style.lineHeight = a.line; // ----- Letter spacing ----- document.body.style.letterSpacing = a.letter + "em"; // ----- Font family ----- // Strip any previous a11y-font-* class ["default", "serif", "dyslexic", "hyperlegible"].forEach(f => { document.body.classList.remove("a11y-font-" + f); }); if (a.font && a.font !== "default") { document.body.classList.add("a11y-font-" + a.font); } // ----- Theme ----- ["dark", "light", "hc-dark", "hc-light"].forEach(t => { document.body.classList.remove("a11y-theme-" + t); }); if (a.theme && a.theme !== "dark") { document.body.classList.add("a11y-theme-" + a.theme); } // ----- Colour-blind filter ----- ["deuteranopia", "protanopia", "tritanopia", "grayscale"].forEach(c => { document.body.classList.remove("a11y-cb-" + c); }); if (a.colorblind && a.colorblind !== "none") { document.body.classList.add("a11y-cb-" + a.colorblind); } // ----- Toggles ----- document.body.classList.toggle("a11y-focus", !!a.focus); document.body.classList.toggle("a11y-hide-images", !!a.hideImages); document.body.classList.toggle("a11y-ruler", !!a.ruler); document.body.classList.toggle("a11y-tts", !!a.tts); document.body.classList.toggle("reduce-motion", !!a.motion); // ----- Sync UI button states ----- syncA11yButtonStates(); } function syncA11yButtonStates() { const a = state.a11y; // Font size document.querySelectorAll("[data-fs]").forEach(b => { b.classList.toggle("active", parseFloat(b.dataset.fs) === a.fs); }); // Line spacing document.querySelectorAll("[data-line]").forEach(b => { b.classList.toggle("active", parseFloat(b.dataset.line) === a.line); }); // Letter spacing document.querySelectorAll("[data-letter]").forEach(b => { b.classList.toggle("active", parseFloat(b.dataset.letter) === a.letter); }); // Font family document.querySelectorAll("[data-font]").forEach(b => { b.classList.toggle("active", b.dataset.font === a.font); }); // Theme document.querySelectorAll(".a11y-btn[data-theme]").forEach(b => { b.classList.toggle("active", b.dataset.theme === a.theme); }); // Colour-blind document.querySelectorAll("[data-cb]").forEach(b => { b.classList.toggle("active", b.dataset.cb === a.colorblind); }); // Toggles const toggleMap = { focus: a.focus, hideImages: a.hideImages, ruler: a.ruler, tts: a.tts, motion: a.motion }; document.querySelectorAll("[data-toggle]").forEach(b => { const key = b.dataset.toggle; b.classList.toggle("active", !!toggleMap[key]); b.textContent = toggleMap[key] ? "On" : "Off"; }); } // Wire up all button handlers document.querySelectorAll("[data-fs]").forEach(btn => { btn.onclick = () => { state.a11y.fs = parseFloat(btn.dataset.fs); applyA11y(); saveLater(); }; }); document.querySelectorAll("[data-line]").forEach(btn => { btn.onclick = () => { state.a11y.line = parseFloat(btn.dataset.line); applyA11y(); saveLater(); }; }); document.querySelectorAll("[data-letter]").forEach(btn => { btn.onclick = () => { state.a11y.letter = parseFloat(btn.dataset.letter); applyA11y(); saveLater(); }; }); document.querySelectorAll("[data-font]").forEach(btn => { btn.onclick = () => { state.a11y.font = btn.dataset.font; applyA11y(); saveLater(); }; }); document.querySelectorAll(".a11y-btn[data-theme]").forEach(btn => { btn.onclick = () => { state.a11y.theme = btn.dataset.theme; applyA11y(); saveLater(); }; }); document.querySelectorAll("[data-cb]").forEach(btn => { btn.onclick = () => { state.a11y.colorblind = btn.dataset.cb; applyA11y(); saveLater(); }; }); document.querySelectorAll("[data-toggle]").forEach(btn => { btn.onclick = () => { const key = btn.dataset.toggle; state.a11y[key] = !state.a11y[key]; applyA11y(); saveLater(); // Stop any in-progress speech when TTS is toggled off if (key === "tts" && !state.a11y.tts && window.speechSynthesis) { window.speechSynthesis.cancel(); document.querySelectorAll(".tts-speaking").forEach(el => el.classList.remove("tts-speaking")); } }; }); async function resetA11y() { state.a11y = { ...A11Y_DEFAULTS }; applyA11y(); await saveNow(); toast("Accessibility settings reset"); } // ----- Reading ruler: follow cursor Y position ----- document.addEventListener("mousemove", e => { if (!state.a11y || !state.a11y.ruler) return; const ruler = document.getElementById("reading-ruler"); if (!ruler) return; ruler.style.top = (e.clientY - 15) + "px"; }); // ----- Read-aloud (Text-to-Speech) ----- let currentSpeech = null; document.addEventListener("click", e => { if (!state.a11y || !state.a11y.tts) return; // Only in student mode — avoid interfering with edit clicks if (isAdminMode) return; // Don't trigger if click was on an interactive element if (e.target.closest("button, a, input, textarea, select")) return; const block = e.target.closest(".block"); if (!block) return; const type = block.dataset.type; if (!["h1", "h2", "text", "quote", "bullet"].includes(type)) return; // Cancel any current speech if (window.speechSynthesis) { window.speechSynthesis.cancel(); document.querySelectorAll(".tts-speaking").forEach(el => el.classList.remove("tts-speaking")); } // If user clicked the same block that was speaking, just stop if (currentSpeech && currentSpeech.block === block) { currentSpeech = null; return; } // Find the text content (textarea value in admin or rendered text in student) const ta = block.querySelector("textarea"); const text = ta ? ta.value : block.textContent; if (!text || !text.trim()) return; if (!window.speechSynthesis) { toast("Read-aloud not supported in this browser", true); return; } const utter = new SpeechSynthesisUtterance(text.trim()); utter.rate = 1.0; utter.pitch = 1.0; utter.volume = 1.0; utter.onstart = () => { block.classList.add("tts-speaking"); }; utter.onend = () => { block.classList.remove("tts-speaking"); currentSpeech = null; }; utter.onerror = () => { block.classList.remove("tts-speaking"); currentSpeech = null; }; currentSpeech = { block, utter }; window.speechSynthesis.speak(utter); }); // Close menus on outside click document.addEventListener("click", e => { if (!e.target.closest(".add-menu")) { document.querySelectorAll(".add-options").forEach(o => o.classList.remove("show")); addMenuOpen = false; } }); // Close a modal only when a click BOTH starts and ends on the backdrop itself. // Without the mousedown guard, selecting text in an input and releasing the // mouse over the backdrop would register as a backdrop "click" and close it. function closeModalOnBackdrop(modalId, closeFn) { const el = document.getElementById(modalId); if (!el) return; let downOnBackdrop = false; el.addEventListener("mousedown", e => { downOnBackdrop = (e.target.id === modalId); }); el.addEventListener("click", e => { if (e.target.id === modalId && downOnBackdrop) closeFn(); downOnBackdrop = false; }); } closeModalOnBackdrop("modal-a11y", closeA11y); closeModalOnBackdrop("modal-new", closeModal); closeModalOnBackdrop("modal-team-logos", closeTeamLogosModal); closeModalOnBackdrop("modal-apply-layout", closeApplyLayoutModal); document.addEventListener("keydown", e => { if (e.key === "Escape") { closeModal(); closeTeamLogosModal(); closeApplyLayoutModal(); closeA11y(); closePasswordModal(); } }); // ============ TEAM LIQUID BOARD CARD INJECTION ============ async function injectBoardData() { const _TL_BANNER = "images/0002-team-liquid-banner.jpg"; const _TL_LOGO = "images/0001-team-team-liquid.jpg"; if (!state.teamLogos) state.teamLogos = {}; if (!state.teamLogos["Team Liquid"]) state.teamLogos["Team Liquid"] = _TL_LOGO; // Remove any previous TL board injections state.jobs = (state.jobs || []).filter(j => !(j._injectionId && j._injectionId.startsWith("tl-board-"))); const MARKER = "tl-board-v1"; if (!state.jobs.find(j => j._injectionId === MARKER)) { const job = { id: "tl-worlds-card", _injectionId: MARKER, num: "01", title: "Team Liquid — Worlds 2026 Social Campaign", team: "Team Liquid", cat: "Social Media", dur: "180 min", banner: _TL_BANNER, logo: _TL_LOGO, fileUrl: "teamliquidsocialworld.html", subpages: [] }; state.jobs.unshift(job); if (typeof renumberBriefs === "function") renumberBriefs(); await saveNow(); } } // ============ BOOT ============ (async () => { const hadArtifactData = !!(await tryArtifactGet(KEY)); const hadLocalData = !!tryLocalGet(KEY); const isFreshSession = !hadArtifactData && !hadLocalData; await loadState(); // In an exported/flat build the TL card already lives in the embedded state; // skip re-injecting (its base64 constants are stripped from the file anyway). if (!window.__BRIEF_GG_EXPORTED__ && !window.__BRIEF_GG_FLAT__) { await injectBoardData(); } // Strip any stray data-theme attributes off root elements (runtime artifacts // that would otherwise fire the theme handler on every page click) document.documentElement.removeAttribute("data-theme"); document.body.removeAttribute("data-theme"); document.documentElement.classList.remove("active"); if (!state.a11y) state.a11y = {}; state.a11y.theme = "dark"; applyA11y(); updateSaveStatus(); if (isFreshSession && !window.__BRIEF_GG_EDITABLE_BACKUP__) { document.getElementById("welcome-banner").classList.add("show"); } // Auto-backup nag: every 10 minutes, if there are unsaved edits, remind to backup let lastBackupTime = Date.now(); setInterval(() => { // Only nag in admin mode and only if there have been edits since last backup if (!isAdminMode) return; if (!hasEverEdited) return; const minsSince = (Date.now() - lastBackupTime) / 60000; if (minsSince >= 10 && editCount > 0) { const sizeMB = getStateSizeMB(); toast(`⚠ ${editCount} unsaved edits · ${sizeMB.toFixed(1)} MB · hit ↓ Save As soon`, true); } }, 60000); // check every minute // Hook into exportData success to reset the backup timer const _originalExportData = window.exportData; if (typeof exportData === "function") { const orig = exportData; window.exportData = function() { orig.apply(this, arguments); lastBackupTime = Date.now(); }; } // Setup phase (no admin password set yet) → open straight into edit mode so // you can add jobs immediately. Once you set a password, it boots locked // (student mode) and asks for the password to edit. // Edit/lock toggle removed for authoring — the editable board always boots into // edit mode so you can add briefs immediately. BUT the exported flat/read-only // copy must boot LOCKED (isAdminMode=false) so the whole card — banner and logo // included — acts as a link, instead of treating image clicks as admin uploads. if (window.__BRIEF_GG_EXPORTED__ && !window.__BRIEF_GG_EDITABLE_BACKUP__) { lockMode(true); } else { unlockMode(); } // Enter key in password fields document.getElementById("pw-input").addEventListener("keydown", e => { if (e.key === "Enter") submitPassword(); }); document.getElementById("pw-confirm").addEventListener("keydown", e => { if (e.key === "Enter") setPassword(); }); document.getElementById("modal-password").addEventListener("click", e => { if (e.target.id === "modal-password") closePasswordModal(); }); goHome(); })();