Version 1 of 1 · · razie · in person · Current version

Open the page

---
description: My projects: every project you're in, one line each, with New project on top, as a base d2 Page (P-1174)
tags: page, projects
---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>My projects</title>
<style>
:root{--paper:#fbfaf7;--card:#fff;--ink:#1d1d1b;--soft:#6b6a66;--line:#e4e1da;--indigo:#9a4b12;--indigo-bg:#f5e8dc;--slate-bg:#f3f1ec;--leaf:#3f7d4e;--rose:#b23a48;--rose-bg:#f8e6e6;--god:#7b3fb8;--display:'Manrope',system-ui,-apple-system,'Segoe UI',sans-serif;--body:system-ui,-apple-system,'Segoe UI',sans-serif;--mono:ui-monospace,Menlo,Consolas,monospace;color-scheme:light dark}
@media (prefers-color-scheme:dark){:root{--paper:#141413;--card:#1d1d1b;--ink:#ecebe6;--soft:#9c9a93;--line:#33322f;--indigo:#f0a35e;--indigo-bg:#2e2419;--slate-bg:#22211f;--leaf:#6cc08a;--rose:#f08a92;--rose-bg:#3a2022;--god:#c5a0f5}}
*{box-sizing:border-box}html,body{background:var(--paper);color:var(--ink);margin:0}body{font:calc(15px*var(--fz,1))/1.45 var(--body)}
.wrap{max-width:1280px;margin:0 auto;padding:6px clamp(12px,3vw,28px) 48px}
h1{font:800 calc(clamp(26px,4vw,34px)*var(--fz,1))/1.1 var(--display);letter-spacing:-.02em;margin:10px 0 12px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1 .sp{flex:1}a{color:var(--indigo)}.small{font-size:13px;color:var(--soft)}.bad{color:var(--rose)}.okk{color:var(--leaf)}
.btn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:6px 12px;font:600 14px var(--body);cursor:pointer}.btn.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}.btn.sm{padding:3px 9px;font-size:13px}
.new{display:none;gap:8px;align-items:center;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin:0 0 12px}.new.open{display:flex}
.new input,.new select{font:inherit;border:1px solid var(--line);border-radius:8px;padding:6px 9px;background:var(--paper);color:var(--ink)}.new input{width:200px}
.bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 8px}.bar input[type=search]{font:inherit;border:1px solid var(--line);border-radius:8px;padding:6px 10px;background:var(--card);color:var(--ink);width:min(260px,100%)}
.chip{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--line);border-radius:999px;padding:0 9px;font-size:13px;background:var(--card);color:var(--ink);cursor:pointer;white-space:nowrap}
.chip.on{background:color-mix(in srgb,var(--leaf) 14%,transparent);border-color:var(--leaf);color:var(--leaf)}.chip.red{color:var(--rose);text-decoration:line-through;border-color:var(--rose)}.chip b{font-weight:600;color:var(--soft);margin-left:3px}
.list{border:1px solid var(--line);border-radius:12px;background:var(--card);overflow:hidden}
.p{display:grid;grid-template-columns:minmax(150px,1.3fr) 76px 64px minmax(0,2fr) 90px 34px;gap:10px;align-items:center;padding:7px 12px;border-top:1px solid var(--line);min-height:40px}
.p:first-child{border-top:0}.p:hover{background:var(--slate-bg)}.p.hd{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--soft);background:var(--paper);min-height:30px}
.p .nm{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.p .nm a{color:var(--ink);text-decoration:none}.p .nm a:hover{text-decoration:underline}.pin{margin-right:4px}
.lv,.rl{font-size:12px;border-radius:6px;padding:1px 7px;justify-self:start;background:var(--slate-bg);color:var(--soft)}.rl.admin{background:var(--indigo-bg);color:var(--indigo)}
.tg{display:flex;gap:4px;flex-wrap:nowrap;overflow:hidden;align-items:center}.tg .chip{font-size:12px;padding:0 7px}.tg .x{border:0;background:none;color:var(--soft);cursor:pointer;padding:0 0 0 2px;font-size:13px}
.tg input{border:1px dashed var(--line);border-radius:999px;padding:0 8px;width:70px;font:12px var(--body);background:transparent;color:var(--ink)}
.when{font-size:12px;color:var(--soft);white-space:nowrap}.more{position:relative}.more>button{border:0;background:none;color:var(--soft);font-size:18px;cursor:pointer;line-height:1}
.menu{position:absolute;right:0;top:24px;z-index:5;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.18);padding:4px;min-width:150px}.menu button,.menu a{display:block;width:100%;text-align:left;border:0;background:none;color:var(--ink);font:14px var(--body);padding:6px 10px;border-radius:6px;cursor:pointer;text-decoration:none}.menu button:hover,.menu a:hover{background:var(--slate-bg)}
.empty{padding:16px;color:var(--soft)}
@media (max-width:720px){.p{grid-template-columns:1fr auto 30px;row-gap:2px}.p.hd{display:none}.p .lv{display:none}.p .tg{grid-column:1/-1;grid-row:2}.p .when{display:none}.p .rl{grid-column:2}.p .more{grid-column:3;grid-row:1}}
</style></head><body><div class="wrap">
<h1>My projects<span class="sp"></span><button type="button" class="btn primary" id="newb">+ New project</button></h1>
<form class="new" id="newf" autocomplete="off"><span class="small" id="pre"></span><input id="pname" placeholder="name" pattern="[a-z0-9][a-z0-9-]*" required aria-label="Project name" autocapitalize="none" autocorrect="off"><span class="small" id="dom"></span>
<select id="plevel" aria-label="Kind of project"><option value="hero">Hero</option><option value="creator">Creator</option><option value="pro">Pro</option><option value="master">Master</option></select>
<button type="submit" class="btn primary">Create</button><button type="button" class="btn" id="newx">Cancel</button><span class="small" id="pcheck">Lower-case letters, digits and dashes. You'll be its admin.</span></form>
<div class="bar"><input type="search" id="find" placeholder="Find a project" aria-label="Find a project"><span id="tags"></span><span class="small" id="hiding"></span></div>
<div class="list" id="list"><div class="empty">Loading your projects…</div></div>
<p class="small" id="msg"></p>
</div>
<script>
(()=>{"use strict";
const D2=window.d2||{},$=s=>document.querySelector(s),esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const api=(m,u,b)=>D2.api?Promise.resolve(D2.api(m,u,b)):Promise.reject(new Error("E_BRIDGE: this page runs inside d2"));
const msgOf=e=>String(e&&e.message||e).replace(/^E_[A-Z_]+:\s*/,""),say=(t,bad)=>{const m=$("#msg");m.textContent=t||"";m.className="small"+(bad?" bad":"")};
const go=u=>typeof D2.go==="function"?Promise.resolve(D2.go(u)).catch(e=>say(msgOf(e),1)):window.open(u,"_blank");
addEventListener("message",e=>{const t=e.data&&e.data.d2theme;if(!t||typeof t!=="object")return;const r=document.documentElement;for(const k in t)if(/^--[\w-]+$/.test(k))r.style.setProperty(k,String(t[k]))});
const host=location.hostname,ROOT=host.split(".").slice(-2).join("."),purl=p=>"https://"+(p==="d2"?ROOT:p+"."+ROOT)+"/home";
const ago=s=>{if(!s)return"";const d=(Date.now()-Date.parse(s))/864e5;return d<1?"today":d<2?"yesterday":d<30?Math.floor(d)+" days ago":new Date(s).toISOString().slice(0,10)};
let S={projects:[],tags:[],filter:{selected:[],hidden:[]}},q="";
// one read up front: GET /api/v2/me/projects (P-1175); until it ships, /api/v2/me's short list
async function load(){try{const j=await api("GET","/api/v2/me/projects");S={projects:j.projects||[],tags:j.tags||[],filter:j.filter||{selected:[],hidden:[]},prefix:j.prefix??"demo-",domain:j.domain||ROOT}}
catch(e){try{const m=await api("GET","/api/v2/me");S={projects:(m.projects||[]).map(p=>({name:p.name,level:p.level,role:p.role||"",tags:[],pinned:false,owner:false,lastUsed:p.lastUsed})),tags:[],filter:{selected:[],hidden:[]},prefix:"demo-",domain:ROOT,lite:true}}catch(e2){$("#list").innerHTML=`<div class="empty bad">${esc(msgOf(e2))}</div>`;return}}
draw()}
function draw(){const {selected,hidden}=S.filter,ps=[...S.projects].sort((a,b)=>(b.pinned-a.pinned)||String(b.lastUsed||"").localeCompare(String(a.lastUsed||""))||a.name.localeCompare(b.name));
const red=p=>(p.tags||[]).some(t=>hidden.includes(t)),shown=ps.filter(p=>!red(p)&&selected.every(t=>(p.tags||[]).includes(t))&&(!q||p.name.includes(q)));
const counts={};for(const p of ps.filter(p=>!red(p)))for(const t of p.tags||[])counts[t]=(counts[t]||0)+1;
const all=[...new Set([...Object.keys(counts),...hidden])].sort();
$("#tags").innerHTML=all.map(t=>`<button type="button" class="chip${selected.includes(t)?" on":hidden.includes(t)?" red":""}" data-t="${esc(t)}" title="Tap: only these · again: hide them · again: back">${esc(t)}${counts[t]?`<b>${counts[t]}</b>`:""}</button>`).join(" ");
const nh=ps.filter(red).length;$("#hiding").innerHTML=nh?`Hiding ${nh} · <a href="#" id="showall">Show all</a>`:"";
$("#pre").textContent=S.prefix||"";$("#dom").textContent="."+(S.domain||ROOT);
const row=p=>`<div class="p" data-p="${esc(p.name)}"><span class="nm">${p.pinned?'<span class="pin" title="Pinned">📌</span>':""}<a href="${esc(purl(p.name))}" data-go>${esc(p.name)}</a></span><span class="lv">${esc(p.level||"")}</span><span class="rl ${esc(p.role||"")}">${esc(p.role||"")}</span>
<span class="tg">${(p.tags||[]).map(t=>`<span class="chip" data-t="${esc(t)}">${esc(t)}${p.owner?`<button type="button" class="x" data-untag="${esc(t)}" aria-label="Remove ${esc(t)}">×</button>`:""}</span>`).join("")}${p.owner&&!S.lite?`<input class="padd" placeholder="+ tag" maxlength="30" aria-label="Add a tag to ${esc(p.name)}">`:""}</span>
<span class="when" title="${esc(p.lastUsed||"")}">${esc(ago(p.lastUsed))}</span><span class="more"><button type="button" data-menu aria-label="More for ${esc(p.name)}">⋯</button></span></div>`;
$("#list").innerHTML=shown.length?`<div class="p hd"><span>Project</span><span>Kind</span><span>Role</span><span>Tags</span><span>Last used</span><span></span></div>`+shown.map(row).join(""):`<div class="empty">${S.projects.length?"No project matches.":"You're not in any project yet. Create one with + New project."}</div>`}
const putFilter=()=>api("PUT","/api/v2/me/projects/filter",S.filter).catch(e=>say(msgOf(e),1));
const setTags=(p,ts)=>api("PUT","/api/v2/projects/"+encodeURIComponent(p)+"/tags",{tags:ts}).then(load).catch(e=>say(msgOf(e),1));
const P=n=>S.projects.find(x=>x.name===n);
document.addEventListener("click",e=>{const t=e.target;if(!t.closest)return;
const a=t.closest("a[data-go]");if(a){e.preventDefault();go(a.getAttribute("href"));return}
if(t.id==="showall"){e.preventDefault();S.filter.hidden=[];draw();putFilter();return}
const c=t.closest("#tags .chip");if(c){const g=c.dataset.t,f=S.filter;if(f.selected.includes(g)){f.selected=f.selected.filter(x=>x!==g);f.hidden=[...f.hidden,g]}else if(f.hidden.includes(g))f.hidden=f.hidden.filter(x=>x!==g);else f.selected=[...f.selected,g];draw();putFilter();return}
const rc=t.closest(".tg .chip");if(rc&&!t.dataset.untag){const g=rc.dataset.t;if(!S.filter.selected.includes(g)){S.filter.selected=[...S.filter.selected,g];S.filter.hidden=S.filter.hidden.filter(x=>x!==g);draw();putFilter()}return}
if(t.dataset.untag){const n=t.closest(".p").dataset.p;setTags(n,(P(n).tags||[]).filter(x=>x!==t.dataset.untag));return}
document.querySelectorAll(".menu").forEach(m=>{if(!m.contains(t))m.remove()});
if(t.dataset.menu!==undefined){const n=t.closest(".p").dataset.p,p=P(n),m=document.createElement("div");m.className="menu";m.setAttribute("role","menu");
m.innerHTML=`<a href="${esc(purl(n))}" data-go role="menuitem">Open</a>${S.lite?"":`<button type="button" data-pin="${p.pinned?0:1}" role="menuitem">${p.pinned?"Unpin":"Pin to top"}</button>`}${p.role==="admin"?`<a href="${esc(purl(n).replace(/\/home$/,"/d2admin/members"))}" data-go role="menuitem">Members</a><a href="${esc(purl(n).replace(/\/home$/,"/settings"))}" data-go role="menuitem">Settings</a>`:""}`;
t.parentNode.append(m);return}
if(t.dataset.pin){const n=t.closest(".p").dataset.p;api("PUT","/api/v2/me/projects/"+encodeURIComponent(n)+"/pin",{pinned:t.dataset.pin==="1"}).then(load).catch(e=>say(msgOf(e),1))}});
const addTag=i=>{const v=i.value.trim().toLowerCase(),n=i.closest(".p").dataset.p;if(!v)return;const ts=P(n).tags||[];if(ts.includes(v)){i.value="";return}setTags(n,[...ts,v])};
document.addEventListener("keydown",e=>{if(e.target.classList&&e.target.classList.contains("padd")&&e.key==="Enter"){e.preventDefault();addTag(e.target)}});
$("#find").addEventListener("input",e=>{q=e.target.value.trim().toLowerCase();draw()});
const full=v=>!v||!S.prefix||v.startsWith(S.prefix)?v:S.prefix+v,nf=$("#newf"),nm=$("#pname"),pc=$("#pcheck");
$("#newb").onclick=()=>{nf.classList.add("open");nm.focus()};$("#newx").onclick=()=>nf.classList.remove("open");
let tm;nm.addEventListener("input",()=>{clearTimeout(tm);const v=full(nm.value.trim());if(!v)return;tm=setTimeout(async()=>{try{const j=await api("GET","/api/v2/projects/available?name="+encodeURIComponent(v));pc.textContent=j.available?(j.name||v)+"."+(S.domain||ROOT)+" is free.":(j.reason||(j.name||v)+" is taken");pc.className="small "+(j.available?"okk":"bad")}catch(e){}},300)});
nf.addEventListener("submit",async e=>{e.preventDefault();const v=full(nm.value.trim());if(!v)return;pc.textContent="Creating…";pc.className="small";
try{const j=await api("POST","/api/v2/projects",{name:v,level:$("#plevel").value});if(j.go)go(j.go);else go(purl(j.name||v))}catch(x){pc.textContent=msgOf(x);pc.className="small bad"}});
if((window.d2params||D2.params||{}).new!==undefined)$("#newb").click();
load()})();
</script></body></html>