This is a single-page web app that lets you build a multi-page, terminal-style personal portfolio in the browser and then download it as a real static website (HTML folders + CSS) zipped up and ready to host.
I know how to write HTML, but constantly opening individual files just to fix small mistakes gets tedious. I also prefer not to rely on external website builders because of the risks that come with SaaS. That’s why I built this template around my own needs. I can edit everything freely in the browser and then export a clean static site. I even added a backup function after softlocking myself one too many times while testing.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portfolio Builder</title>
<style>
/* ===== app shell (only for the builder toolbar, NOT exported) ===== */
body{margin:0;font-family:ui-monospace,Menlo,Consolas,monospace;background:#050805}
#bar{position:sticky;top:0;z-index:60;display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 16px;background:#111;color:#ddd;font-size:13px;border-bottom:1px solid #2a2a2a}
#bar button,#bar label.b,#bar select{background:#222;color:#eee;border:1px solid #3a3a3a;border-radius:5px;padding:6px 10px;cursor:pointer;font:inherit}
#bar button:hover,#bar label.b:hover{background:#333}
#bar .on{background:#39ff7a;color:#000;border-color:#39ff7a} /* green highlight for active mode button */
#bar input[type=color]{border:0;width:28px;height:28px;padding:0;background:none;vertical-align:middle}
#status{margin-left:auto;opacity:.7} /* "Saved" message appears here */
#help{background:#0d140f;color:#9fc4aa;padding:8px 16px;font-size:13px;border-bottom:1px solid #1c3524}
</style>
</head>
<body>
<!-- sticky top toolbar -->
<div id="bar">
<strong>>_ Portfolio Builder</strong>
<button id="mEdit" class="on">Edit</button><button id="mView">Preview</button>
<select id="theme"><option value="">Theme…</option><option value="#39ff7a">Matrix green</option><option value="#ffb000">Amber</option><option value="#00e5ff">Cyan</option><option value="#ff3df2">Magenta</option><option value="#ff5555">Red</option></select>
<input type="color" id="accent" title="Custom accent">
<button data-top="export">⬇ Download website (.zip)</button>
<button data-top="backup">Save backup</button>
<label class="b">Load backup<input type="file" id="load" accept=".json" hidden></label>
<button data-top="reset">Reset</button>
<span id="status"></span>
</div>
<div id="help"></div> <!-- shows current path + tips -->
<div id="site"></div> <!-- everything is rendered into here -->
<!-- hidden file pickers used by the image / PDF buttons -->
<input type="file" id="img" accept="image/*" hidden>
<input type="file" id="file" accept=".pdf,.zip,.pptx,.docx,.xlsx,.csv,.txt,.ipynb" hidden>
<script>
/* ==========================================================================
PORTFOLIO BUILDER
Single-page app that builds a multi-page terminal-style portfolio.
Data = one big nested object. Edit live → export real HTML folders + ZIP.
========================================================================== */
/* --------------------------------------------------------------------------
SITE CSS – this string becomes style.css when you download the site
-------------------------------------------------------------------------- */
const CSS=`:root{--a:#39ff7a;--bg:#070b08;--fg:#cfe9d6;--mut:#7f9d88;--card:#0d140f;--line:#1c3524;--glow:0 0 12px color-mix(in srgb,var(--a) 45%,transparent)}
*{box-sizing:border-box}
body{margin:0;background:#070b08}
.s{background:var(--bg);color:var(--fg);font:16px/1.65 ui-monospace,"Cascadia Code","SF Mono",Menlo,Consolas,monospace;min-height:100vh}
.s::after{content:"";position:fixed;inset:0;pointer-events:none;z-index:50;background:repeating-linear-gradient(0deg,rgba(0,0,0,.16) 0 1px,transparent 1px 3px)} /* CRT scan-lines */
.s ::selection{background:var(--a);color:#000}
.s nav{position:sticky;top:0;z-index:5;background:rgba(7,11,8,.93);backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:10px 24px;font-size:14px}
.s nav a{color:var(--fg);text-decoration:none}
.s nav a:hover{color:var(--a);text-shadow:var(--glow)}
.s nav b{color:var(--a)}
.s nav .ps{color:var(--a)} /* prompt colour: guest@portfolio */
.s nav .sep{color:var(--mut)}
.s nav .jump{margin-left:12px;color:var(--mut);border:1px solid var(--line);padding:0 8px;border-radius:4px}
.s header{max-width:980px;margin:auto;padding:48px 24px 12px}
.s header.hero{padding-top:72px}
.s .nm{font-size:clamp(26px,5vw,44px);font-weight:700;margin:0;color:var(--a);text-shadow:var(--glow);word-break:break-word}
.s .hero .nm{font-size:clamp(30px,6vw,56px)}
.s .nm::before{content:"> ";opacity:.6} /* terminal prompt before name */
.s .nm::after{content:"\\258C";margin-left:6px;animation:bl 1s steps(1) infinite} /* blinking cursor */
@keyframes bl{50%{opacity:0}}
@media(prefers-reduced-motion:reduce){.s .nm::after{animation:none}}
.s .tg{font-size:15px;color:var(--mut);margin:6px 0 0}
.s .tg::before{content:"// "} /* comment-style subtitle */
.s .blk{max-width:980px;margin:auto;padding:12px 24px}
.s .blk>p{max-width:760px;margin:0;white-space:pre-wrap}
.s h2{font-size:24px;margin:12px 0 8px;color:var(--a)}
.s h2::before{content:"## ";opacity:.6} /* markdown-style heading */
.s .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px;margin-top:14px}
.s .card{background:var(--card);border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;flex-direction:column;color:inherit;text-decoration:none;transition:.15s}
.s a.card:hover{border-color:var(--a);box-shadow:var(--glow);transform:translateY(-2px)}
.s .card img{width:100%;aspect-ratio:16/10;object-fit:cover;display:block;filter:saturate(.7) contrast(1.05)}
.s .cb{padding:14px;display:flex;flex-direction:column;gap:8px;flex:1}
.s h3{margin:0;font-size:18px}
.s h3::before{content:"\\25B8 ";color:var(--a)} /* ▶ arrow before card titles */
.s .ds{color:var(--mut);margin:0;white-space:pre-wrap;font-size:14px}
.s .tags{display:flex;flex-wrap:wrap;gap:6px}
.s .tag{border:1px solid var(--a);color:var(--a);border-radius:3px;padding:0 8px;font-size:12px}
.s .more{color:var(--a);margin-top:auto;font-size:14px}
.s .btn{display:inline-block;border:1px solid var(--a);color:var(--a);padding:5px 16px;border-radius:4px;text-decoration:none;font-weight:700;transition:.15s}
.s a.btn:hover{background:var(--a);color:#000;box-shadow:var(--glow)}
.s figure{margin:8px 0}
.s figure img{max-width:100%;border:1px solid var(--line);border-radius:6px;display:block}
.s figcaption{color:var(--mut);font-size:13px;margin-top:6px}
.s .ph{border:1px dashed var(--line);color:var(--mut);padding:40px;text-align:center;border-radius:6px} /* placeholder when no image */
.s .term{border:1px solid var(--line);border-radius:6px;background:#040704;overflow:hidden}
.s .term .bar{display:flex;gap:6px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--line);color:var(--mut);font-size:12px}
.s .term .bar i{width:10px;height:10px;border-radius:50%;background:var(--line)} /* fake traffic-light dots */
.s .term .bar .lang{margin-left:6px}
.s pre.code{margin:0;padding:14px 16px;overflow:auto;white-space:pre;color:#b5f5c7;font:inherit;font-size:14px;tab-size:2}
.s .note{border-left:3px solid var(--a);background:var(--card);padding:10px 16px;white-space:pre-wrap;border-radius:0 6px 6px 0}
.s .note::before{content:"[!] ";color:var(--a)} /* callout prefix */
.s .fl{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border:1px solid var(--line);background:var(--card);padding:10px 14px;border-radius:6px}
.s .meta{color:var(--mut);font-size:13px}
.s .pdf iframe{width:100%;height:80vh;border:1px solid var(--line);border-radius:6px;background:#fff;margin-top:10px}
.s .ft{max-width:980px;margin:48px auto 0;padding:24px;color:var(--mut);font-size:13px;border-top:1px solid var(--line)}
.s .ft::before{content:"$ ";color:var(--a)} /* footer looks like a shell prompt */
`;
/* --------------------------------------------------------------------------
EDITOR-ONLY CSS – only injected while edit=true
Makes contenteditable areas visible + styles the ↑↓Delete buttons
-------------------------------------------------------------------------- */
const EDIT_CSS=`
.s::after{display:none} /* hide scan-lines while editing so text is clearer */
.s nav{top:50px} /* push nav below the builder toolbar */
[contenteditable]{outline:1px dashed transparent;border-radius:4px;min-width:30px;display:inline-block}
h1[contenteditable],h2[contenteditable],h3[contenteditable],p[contenteditable],pre[contenteditable],.note[contenteditable]{display:block}
.s .blk>p[contenteditable]{min-height:1.6em}
[contenteditable]:hover{outline-color:#555}
[contenteditable]:focus{outline:2px solid var(--a);background:rgba(255,255,255,.05)}
[contenteditable]:empty:before{content:attr(data-ph);opacity:.4} /* placeholder text when empty */
.ctl{display:flex;gap:4px;flex-wrap:wrap;margin:6px 0;align-items:center}
.ctl .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-right:4px}
.ctl button,.addbar button{font:12px ui-monospace,monospace;padding:3px 9px;border:1px solid var(--line);background:#121c15;color:var(--fg);border-radius:4px;cursor:pointer}
.ctl button:hover,.addbar button:hover{border-color:var(--a);color:var(--a)}
.ctl .x:hover{border-color:#f55;color:#f55} /* red hover for delete */
.ctl .go{background:var(--a);color:#000;border-color:var(--a)}
.ctl .go:hover{color:#000;box-shadow:var(--glow)}
.addbar{max-width:980px;margin:24px auto;padding:16px 24px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;border-top:1px dashed var(--line);color:var(--mut);font-size:13px}
`;
/* --------------------------------------------------------------------------
DATA HELPERS
-------------------------------------------------------------------------- */
// short random id for pages (e.g. "k7x2p9q")
const uid=()=>Math.random().toString(36).slice(2,9);
// create a brand-new child page with some starter blocks
const newPage=(t="New page",s="",g="")=>({id:uid(),title:t,summary:s,tags:g,img:"",blocks:[{t:"p",text:"Explain this page here."},{t:"h",text:"What I learned"},{t:"p",text:""}]});
// create a new content block of a given type (h/p/img/code/note/link/pdf/sec)
const newBlock=t=>({h:{t:"h",text:"Heading"},p:{t:"p",text:""},img:{t:"img",src:"",caption:""},link:{t:"link",text:"Link text",url:"https://"},sec:{t:"sec",title:"Section",items:[]},
code:{t:"code",lang:"python",text:""},note:{t:"note",text:"Key takeaway…"},pdf:{t:"pdf",title:"Download PDF",name:"",src:"",mime:"",size:0,embed:true}}[t]);
// the default portfolio shown on first visit / after Reset
const DEF=()=>({id:"root",title:"Your Name",summary:"MSc Candidate · Your Program",accent:"#39ff7a",img:"",tags:"",blocks:[
{t:"p",text:"Write a short intro about yourself and what this portfolio shows."},
{t:"sec",title:"Master’s classes",items:[newPage("Class one","One-line summary of the class","Semester 1"),newPage("Class two","One-line summary of the class","Semester 1")]}]});
// ----- global state -----
let D=DEF(), // the whole portfolio tree (root page)
edit=true, // true = edit mode, false = preview mode
nav=[], // stack of page ids = current path (empty = root)
imgPath=null, // which data-path is waiting for an image upload
fileIdx=null, // which block index is waiting for a file upload
tm; // timer used to debounce autosave
// open (or create) the IndexedDB database named "pb"
const idb=()=>new Promise(r=>{try{const q=indexedDB.open("pb",1);q.onupgradeneeded=()=>q.result.createObjectStore("kv");q.onsuccess=()=>r(q.result);q.onerror=()=>r(null)}catch(e){r(null)}});
// read a key from the "kv" object store
const kvGet=async k=>{const db=await idb();return db?new Promise(r=>{const q=db.transaction("kv").objectStore("kv").get(k);q.onsuccess=()=>r(q.result??null);q.onerror=()=>r(null)}):null};
// write a key/value into the "kv" object store
const kvSet=async(k,v)=>{const db=await idb();if(!db)throw 0;return new Promise((r,j)=>{const t=db.transaction("kv","readwrite");t.objectStore("kv").put(v,k);t.oncomplete=r;t.onerror=j})};
// debounced autosave – waits 400 ms after last change then writes to IDB
function save(){clearTimeout(tm);tm=setTimeout(async()=>{try{await kvSet("D",D);st("Saved")}catch(e){st("Could not save – use Save backup")}},400)}
// show a short status message in the toolbar (auto-clears after 2.5 s)
function st(t){const e=document.getElementById("status");e.textContent=t;clearTimeout(st.t);st.t=setTimeout(()=>e.textContent="",2500)}
// HTML-escape so user text can't break the markup
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
// return all direct child pages of a page (look inside every "sec" block)
const kids=p=>p.blocks.filter(b=>b.t==="sec").flatMap(b=>b.items);
// walk the nav path and return the page the user is currently looking at
function cur(){let p=D;for(const id of nav)p=kids(p).find(k=>k.id===id)||p;return p}
// return the full chain [root, …, current] used for breadcrumbs
function pages(){const a=[D];let p=D;for(const id of nav){p=kids(p).find(k=>k.id===id);if(!p)break;a.push(p)}return a}
// set a nested property by dot-path, e.g. setP(obj, "blocks.0.text", "hi")
function setP(o,path,v){const k=path.split(".");for(let i=0;i<k.length-1;i++)o=o[k[i]];o[k[k.length-1]]=v}
// swap two items in an array (used for ↑ / ↓ reordering)
const swap=(a,i,j)=>{if(j>=0&&j<a.length)[a[i],a[j]]=[a[j],a[i]]};
// give a page (and all its descendants) brand-new unique ids – needed when cloning
const reid=p=>{p.id=uid();kids(p).forEach(reid)};
// deep-clone a page, give it new ids, append " (copy)" to the title
const clone=p=>{const c=JSON.parse(JSON.stringify(p));reid(c);c.title+=" (copy)";return c};
// turn a title into a URL-safe slug ("My Project!" → "my-project")
const slug=s=>String(s).normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")||"page";
// walk the whole tree and assign a unique _s (slug) property to every page
function assignSlugs(p){const u=new Set();kids(p).forEach(it=>{let s=slug(it.title),x=s,k=2;while(u.has(x))x=s+"-"+k++;u.add(x);it._s=x;assignSlugs(it)})}
// human-readable file size ("1.2 MB" or "45 KB")
const sz=n=>n>1048576?(n/1048576).toFixed(1)+" MB":Math.max(1,Math.round(n/1024))+" KB";
// convert a data-URL (base64) into a raw Uint8Array of bytes
const b64=u=>{const s=atob(u.split(",")[1]),a=new Uint8Array(s.length);for(let i=0;i<s.length;i++)a[i]=s.charCodeAt(i);return a};
/* --------------------------------------------------------------------------
RENDERING – shared by the live editor AND the static export
-------------------------------------------------------------------------- */
// build an editable (or static) HTML element
// path = data-path so the input handler knows what to update
const ed=(path,v,ph,tag,cls,o)=>`<${tag} class="${cls}" ${o.edit?`contenteditable="plaintext-only" data-p="${path}" data-ph="${ph}"`:""}>${esc(v)}</${tag}>`;
// the little ↑ ↓ Delete control bar that appears above every block in edit mode
const ctl=(i,label,o,extra="")=>o.edit?`<div class="ctl"><span class="lbl">${label}</span><button data-a="bUp" data-b="${i}">↑</button><button data-a="bDown" data-b="${i}">↓</button>${extra}<button class="x" data-a="bDel" data-b="${i}">Delete</button></div>`:"";
// build the terminal-style breadcrumb: guest@portfolio:~/class-one$
// link(i) returns the attributes for the i-th crumb
function mkNav(chain,link){const L=chain.length-1;
const path=chain.map((q,i)=>i===L?`<b>${i?esc(q._s):"~"}</b>`:`<a ${link(i)}>${i?esc(q._s):"~"}</a>`).join('<span class="sep">/</span>');
return `<nav><span class="ps">guest@portfolio</span>:${path}<span class="ps">$</span>${L>=1?` <a class="jump" ${link(L-1)}>cd ..</a>`:""}${L>=2?` <a class="jump" ${link(0)}>cd ~</a>`:""}</nav>`}
// render the full HTML body of one page
// o.edit / o.nav / o.img / o.file / o.card control edit-vs-export behaviour
function body(p,o,root){
// start with the nav + page title / subtitle
let h=`<div class="s">${o.nav}<header class="${root?"hero":""}">${ed("title",p.title,"Title","h1","nm",o)}${ed("summary",p.summary,"Short subtitle","p","tg",o)}</header>`;
// loop over every block on this page
p.blocks.forEach((b,i)=>{const P=`blocks.${i}`;h+=`<div class="blk">`;
if(b.t==="h")h+=ctl(i,"Heading",o)+ed(P+".text",b.text,"Heading","h2","",o);
if(b.t==="p")h+=ctl(i,"Text",o)+ed(P+".text",b.text,"Write something…","p","",o);
if(b.t==="note")h+=ctl(i,"Note",o)+ed(P+".text",b.text,"Callout text","div","note",o);
if(b.t==="code")h+=ctl(i,"Code",o)+`<div class="term"><div class="bar"><i></i><i></i><i></i>${ed(P+".lang",b.lang,"language","span","lang",o)}</div>${ed(P+".text",b.text,"// paste code here","pre","code",o)}</div>`;
if(b.t==="img")h+=ctl(i,"Image",o,`<button data-img="${P}.src">${b.src?"Change":"Choose"} image</button>`)+`<figure>${b.src?`<img src="${o.img(b.src)}" alt="">`:`<div class="ph">No image yet</div>`}${ed(P+".caption",b.caption,"Caption","figcaption","",o)}</figure>`;
if(b.t==="link")h+=ctl(i,"Link",o)+(o.edit?ed(P+".text",b.text,"Button text","span","btn",o)+" "+ed(P+".url",b.url,"https://…","span","",o):`<a class="btn" href="${esc(b.url)}" target="_blank" rel="noopener">${esc(b.text)}</a>`);
if(b.t==="pdf"){const has=!!b.src,isPdf=/pdf/i.test(b.mime||b.name||""),u=has&&!o.edit?o.file(b):"";
// file download row + optional embedded PDF viewer
h+=ctl(i,"PDF / file",o,`<button data-file="${i}">${has?"Replace":"Choose"} file</button>${has&&isPdf?`<button data-a="embed" data-b="${i}">Viewer: ${b.embed?"on":"off"}</button>`:""}`)+
`<div class="fl">${o.edit?ed(P+".title",b.title,"Label","span","btn",o):`<a class="btn" ${has?`href="${esc(u)}" download="${esc(b.name)}"`:'href="#"'}>⬇ ${esc(b.title)}</a>`}<span class="meta">${has?esc(b.name)+" · "+sz(b.size):"no file chosen yet"}</span></div>`+
(u&&isPdf&&b.embed?`<div class="pdf"><iframe src="${esc(u)}" title="${esc(b.title)}"></iframe></div>`:"")}
if(b.t==="sec"){
// a "cards section" = a folder of child pages shown as a grid of cards
h+=ctl(i,"Cards section",o,`<button data-a="addItem" data-b="${i}">+ New page</button>`)+ed(P+".title",b.title,"Section title","h2","",o)+`<div class="grid">`;
b.items.forEach((it,j)=>{const Q=`${P}.items.${j}`,tg=it.tags.split(",").map(t=>t.trim()).filter(Boolean),tag=o.edit?"div":"a";
// in edit mode the card is a <div>; in preview/export it becomes a real <a>
h+=`<${tag} class="card" ${o.edit?"":o.card(it)}>${it.img?`<img src="${o.img(it.img)}" alt="">`:""}<div class="cb">${o.edit?`<div class="ctl"><button class="go" data-open="${it.id}">Open page ▸</button><button data-img="${Q}.img">${it.img?"Change":"Add"} image</button>${it.img?`<button data-noimg="${Q}.img">No image</button>`:""}<button data-a="iUp" data-b="${i}" data-i="${j}">↑</button><button data-a="iDown" data-b="${i}" data-i="${j}">↓</button><button data-a="iDup" data-b="${i}" data-i="${j}">Duplicate</button><button class="x" data-a="iDel" data-b="${i}" data-i="${j}">Delete</button></div>`:""}${ed(Q+".title",it.title,"Page title","h3","",o)}${ed(Q+".summary",it.summary,"Short summary","p","ds",o)}${o.edit?`<div class="tags">${ed(Q+".tags",it.tags,"tags, comma separated","span","tag",o)}</div>`:`<div class="tags">${tg.map(t=>`<span class="tag">${esc(t)}</span>`).join("")}</div><span class="more">open →</span>`}</div></${tag}>`});
h+=`</div>`}
h+=`</div>`});
// the "Add …" toolbar at the bottom (edit mode only)
if(o.edit)h+=`<div class="addbar">Add to this page: <button data-add="h">+ Heading</button><button data-add="p">+ Text</button><button data-add="img">+ Image</button><button data-add="pdf">+ PDF / file</button><button data-add="code">+ Code</button><button data-add="note">+ Note</button><button data-add="link">+ Link</button><button data-add="sec">+ Cards section (folders)</button></div>`;
// footer
return h+`<div class="ft">exit 0 — ${esc(D.title)} · ${new Date().getFullYear()}</div></div>`}
/* --------------------------------------------------------------------------
LIVE EDITOR
-------------------------------------------------------------------------- */
const styleEl=document.head.appendChild(document.createElement("style")), // <style> we keep updating
URLS=new WeakMap(); // cache of object-URLs for embedded files (avoids recreating blobs)
// rebuild the whole #site element from the current page
function draw(){
assignSlugs(D); // make sure every page has a unique slug
const ps=pages(),p=ps[ps.length-1]; // chain of pages + the one we are viewing
// inject accent colour + site CSS + (if editing) the editor CSS
styleEl.textContent=`:root{--a:${D.accent}}`+CSS+(edit?EDIT_CSS:"");
// options object that tells body() how to render
const o={
edit,
img:s=>s, // in editor, images stay as data-URLs
card:it=>`href="#" data-open="${it.id}"`, // click card → open page
nav:mkNav(ps,i=>`href="#" data-go="${i?ps[i].id:"root"}"`),
// for PDFs: create a temporary blob URL so the iframe can show it
file:b=>{let c=URLS.get(b);if(!c||c.s!==b.src){c={s:b.src,u:URL.createObjectURL(new Blob([b64(b.src)],{type:b.mime||"application/octet-stream"}))};URLS.set(b,c)}return c.u}
};
document.getElementById("site").innerHTML=body(p,o,ps.length===1);
// update the help bar with the current path
const path="/"+ps.slice(1).map(q=>q._s).join("/")+(ps.length>1?"/":"");
document.getElementById("help").innerHTML=edit
?`Editing <b>${path}index.html</b> · click any text to edit · “Open page ▸” on a card goes into its folder · <b>~</b> in the top bar always returns to the root.`
:"Preview: click cards to move between pages, exactly as on the final site.";
document.getElementById("accent").value=D.accent;
// highlight the active mode button
document.getElementById("mEdit").className=edit?"on":"";
document.getElementById("mView").className=edit?"":"on";
}
// redraw + scroll to top (used after navigating)
const go=()=>{draw();scrollTo(0,0)};
const SITE=document.getElementById("site");
// whenever the user types in a contenteditable field, write it back into D
SITE.addEventListener("input",e=>{
const p=e.target.dataset.p;
if(p){setP(cur(),p,e.target.innerText.replace(/\n$/,""));save()} // strip trailing newline browsers sometimes add
});
// single click handler for ALL editor buttons / navigation
SITE.addEventListener("click",e=>{
const el=e.target.closest("[data-go],[data-open],[data-a],[data-add],[data-img],[data-noimg],[data-file]");
if(!el)return;
e.preventDefault();
const d=el.dataset,p=cur();
// breadcrumb click → jump to that level
if(d.go){nav=d.go==="root"?[]:nav.slice(0,nav.indexOf(d.go)+1);return go()}
// open a child page (push its id onto the nav stack)
if(d.open){nav.push(d.open);return go()}
// open the hidden image file picker
if(d.img){imgPath=d.img;document.getElementById("img").click();return}
// open the hidden PDF/file picker
if(d.file){fileIdx=+d.file;document.getElementById("file").click();return}
// remove an image
if(d.noimg)setP(p,d.noimg,"");
// add a new block of the chosen type
if(d.add)p.blocks.push(newBlock(d.add));
// block / card actions (reorder, delete, duplicate, toggle PDF embed…)
if(d.a){
const b=+d.b,i=+d.i,blk=p.blocks[b];
if(d.a==="bUp")swap(p.blocks,b,b-1); // move block up
if(d.a==="bDown")swap(p.blocks,b,b+1); // move block down
if(d.a==="bDel"&&confirm("Delete this block?"))p.blocks.splice(b,1);
if(d.a==="embed")blk.embed=!blk.embed; // toggle PDF iframe
if(d.a==="addItem")blk.items.push(newPage()); // add a new child page
if(d.a==="iUp")swap(blk.items,i,i-1); // move card up
if(d.a==="iDown")swap(blk.items,i,i+1); // move card down
if(d.a==="iDup")blk.items.splice(i+1,0,clone(blk.items[i])); // duplicate card
if(d.a==="iDel"&&confirm("Delete this page and everything inside it?"))blk.items.splice(i,1);
}
save();draw();
});
// image upload: resize to max 900 px wide, store as JPEG data-URL
document.getElementById("img").addEventListener("change",e=>{
const f=e.target.files[0];if(!f||!imgPath)return;
const r=new FileReader();
r.onload=()=>{
const im=new Image();
im.onload=()=>{
// scale down if wider than 900 px
const k=Math.min(1,900/im.width),c=document.createElement("canvas");
c.width=im.width*k;c.height=im.height*k;
c.getContext("2d").drawImage(im,0,0,c.width,c.height);
setP(cur(),imgPath,c.toDataURL("image/jpeg",.8)); // 80 % quality JPEG
save();draw();
};
im.src=r.result;
};
r.readAsDataURL(f);
e.target.value=""; // allow re-selecting the same file later
});
// file (PDF etc.) upload: store as data-URL + keep name / size / mime
document.getElementById("file").addEventListener("change",e=>{
const f=e.target.files[0];if(!f)return;
const r=new FileReader();
r.onload=()=>{
const b=cur().blocks[fileIdx];
b.src=r.result;b.name=f.name;b.size=f.size;
b.mime=f.type||(/\.pdf$/i.test(f.name)?"application/pdf":"");
// if the user hasn't renamed the label yet, use the filename
if(b.title==="Download PDF")b.title=f.name.replace(/\.[^.]+$/,"");
save();draw();
};
r.readAsDataURL(f);
e.target.value="";
});
// accent colour picker
document.getElementById("accent").addEventListener("input",e=>{D.accent=e.target.value;save();draw()});
// preset theme dropdown
document.getElementById("theme").addEventListener("change",e=>{if(e.target.value){D.accent=e.target.value;save();draw()}e.target.value=""});
// Edit / Preview mode toggle
document.getElementById("mEdit").onclick=()=>{edit=true;draw()};
document.getElementById("mView").onclick=()=>{edit=false;draw()};
/* --------------------------------------------------------------------------
STATIC SITE EXPORT – real folders + ZIP
-------------------------------------------------------------------------- */
// pre-computed CRC-32 table (needed for correct ZIP file headers)
const crcT=(()=>{const t=[];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})();
// calculate CRC-32 of a byte array
const crc=d=>{let c=~0;for(let i=0;i<d.length;i++)c=crcT[(c^d[i])&255]^(c>>>8);return ~c>>>0};
// build a ZIP Blob from an array of {path, bytes}
// uses the classic local-file + central-directory + end-of-central-dir layout
// (no compression – store method only)
function zip(files){
const enc=new TextEncoder(),loc=[],cen=[];let off=0,cs=0;
for(const f of files){
const nm=enc.encode(f.path),d=f.bytes,c=crc(d);
// ----- local file header (30 bytes) -----
const h=new DataView(new ArrayBuffer(30));
h.setUint32(0,0x04034b50,true); // signature
h.setUint16(4,20,true); // version needed
h.setUint16(6,0x0800,true); // UTF-8 flag
h.setUint16(12,33,true); // mod time (dummy)
h.setUint32(14,c,true); // CRC-32
h.setUint32(18,d.length,true); // compressed size
h.setUint32(22,d.length,true); // uncompressed size
h.setUint16(26,nm.length,true); // filename length
// ----- central directory header (46 bytes) -----
const g=new DataView(new ArrayBuffer(46));
g.setUint32(0,0x02014b50,true);
g.setUint16(4,20,true);g.setUint16(6,20,true);g.setUint16(8,0x0800,true);
g.setUint16(14,33,true);g.setUint32(16,c,true);
g.setUint32(20,d.length,true);g.setUint32(24,d.length,true);
g.setUint16(28,nm.length,true);g.setUint32(42,off,true); // offset of local header
loc.push(h.buffer,nm,d);cen.push(g.buffer,nm);
off+=30+nm.length+d.length;cs+=46+nm.length;
}
// ----- end of central directory (22 bytes) -----
const e=new DataView(new ArrayBuffer(22));
e.setUint32(0,0x06054b50,true);
e.setUint16(8,files.length,true);e.setUint16(10,files.length,true);
e.setUint32(12,cs,true);e.setUint32(16,off,true);
return new Blob([...loc,...cen,e.buffer],{type:"application/zip"});
}
// tiny SVG favicon (the ">_" glyph) as a data-URL
const ICON="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%23070b08'/%3E%3Ctext x='4' y='23' font-family='monospace' font-size='18' font-weight='bold' fill='%2339ff7a'%3E%3E_%3C/text%3E%3C/svg%3E";
// walk the page tree → one index.html per page + extract images/files → ZIP
function exportSite(){
assignSlugs(D);
const files=[],enc=new TextEncoder();
// recursive builder: p = current page, dirs = path segments, chain = ancestor pages
(function build(p,dirs,chain){
const depth=dirs.length,up="../".repeat(depth),used=new Set(["index.html"]);
// keep filenames unique inside a folder
const uniq=name=>{const base=String(name).replace(/[^\w.\-]+/g,"_")||"file";let nm=base,k=2;while(used.has(nm))nm=base.replace(/(\.[^.]*)?$/,`-${k++}$1`);used.add(nm);return nm};
// write a file into the zip and return the (possibly uniquified) name
const put=(name,bytes)=>{const nm=uniq(name);files.push({path:[...dirs,nm].join("/"),bytes});return nm};
// options for the STATIC render: real relative hrefs + extract assets to real files
const all=[...chain,p],
o={
edit:false,
nav:mkNav(all,i=>`href="${"../".repeat(depth-i)}index.html"`),
card:it=>`href="${it._s}/index.html"`,
img:src=>put("image.jpg",b64(src)), // extract image data-URL → real .jpg
file:b=>put(b.name||"file",b64(b.src)) // extract any attached file
};
// generate the HTML for this page
const html=`<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${depth?esc(p.title)+" | ":""}${esc(D.title)}</title><meta name="description" content="${esc(p.summary)}"><meta name="theme-color" content="#070b08"><link rel="icon" href="${ICON}"><link rel="stylesheet" href="${up}style.css"></head><body>\n${body(p,o,!depth)}\n</body></html>`;
files.push({path:[...dirs,"index.html"].join("/"),bytes:enc.encode(html)});
// recurse into every child page (each gets its own sub-folder)
kids(p).forEach(it=>build(it,[...dirs,it._s],all));
})(D,[],[]);
// shared stylesheet (with the user's chosen accent colour)
files.push({path:"style.css",bytes:enc.encode(`:root{--a:${D.accent}}\n`+CSS)});
dl("portfolio-site.zip",zip(files));
}
// trigger a browser download
function dl(name,data,type){
const a=document.createElement("a");
a.href=URL.createObjectURL(data instanceof Blob?data:new Blob([data],{type}));
a.download=name;
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000); // free memory after a second
}
// toolbar buttons: Export / Backup / Reset
document.getElementById("bar").addEventListener("click",e=>{
const t=e.target.dataset.top;if(!t)return;
if(t==="export")exportSite();
if(t==="backup")dl("portfolio-backup.json",JSON.stringify(D),"application/json");
if(t==="reset"&&confirm("Erase everything and start over?")){D=DEF();nav=[];save();go()}
});
// load a previously saved JSON backup
document.getElementById("load").addEventListener("change",e=>{
const f=e.target.files[0];if(!f)return;
const r=new FileReader();
r.onload=()=>{try{D=JSON.parse(r.result);nav=[];save();go()}catch(x){alert("That file isn’t a valid backup.")}};
r.readAsText(f);
e.target.value="";
});
/* --------------------------------------------------------------------------
BOOTSTRAP – load saved work (or migrate old localStorage version)
-------------------------------------------------------------------------- */
(async()=>{
let x=await kvGet("D"); // try IndexedDB first
// fall back to the old localStorage key if nothing in IDB
if(!x){try{const s=localStorage.getItem("pb2");if(s){x=JSON.parse(s);if(x.accent==="#2f6fed")x.accent="#39ff7a"}}catch(e){}}
if(x)D=x;
draw(); // first paint
})();
</script>
</body>
</html>