Self-contained single-file HTML export for cards. Mobile-first,
dark theme, zero dependencies. Each card type gets its own
interactive treatment: snippet tap-to-copy, template slot filling,
checklist with progress bar, decision structured layout, link
tap targets. Filter chips by type, search across all cards.
Usage: nib export -f html -o deck.html
nib export -f html -t triage -o triage.html
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
|
||||
:root{
|
||||
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
|
||||
--border:#2e2e2c;--border-focus:#4a4a47;
|
||||
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
|
||||
--accent:#c4a46c;--accent2:#a68a52;
|
||||
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
|
||||
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
|
||||
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
|
||||
--card-radius:12px;
|
||||
--safe-bottom:env(safe-area-inset-bottom,0px);
|
||||
}
|
||||
|
||||
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
font-family:var(--sans);color:var(--text);background:var(--bg);
|
||||
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
|
||||
.deck-header{
|
||||
position:sticky;top:0;z-index:10;
|
||||
background:var(--bg);border-bottom:1px solid var(--border);
|
||||
padding:16px 20px 12px;
|
||||
}
|
||||
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
|
||||
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
|
||||
|
||||
.filter-bar{
|
||||
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
|
||||
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||
}
|
||||
.filter-bar::-webkit-scrollbar{display:none}
|
||||
.filter-chip{
|
||||
flex-shrink:0;padding:6px 14px;border-radius:20px;
|
||||
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
|
||||
background:var(--surface);color:var(--text2);cursor:pointer;
|
||||
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||
|
||||
.search-wrap{padding:8px 20px 4px}
|
||||
.search-input{
|
||||
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
|
||||
background:var(--surface);color:var(--text);font-size:0.9rem;
|
||||
font-family:var(--sans);outline:none;transition:border-color 0.15s;
|
||||
}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
.search-input::placeholder{color:var(--text3)}
|
||||
|
||||
.card-list{padding:8px 16px}
|
||||
|
||||
.card{
|
||||
background:var(--surface);border:1px solid var(--border);
|
||||
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
|
||||
transition:border-color 0.15s;position:relative;
|
||||
}
|
||||
.card:active{border-color:var(--border-focus)}
|
||||
.card.pinned{border-left:3px solid var(--accent)}
|
||||
|
||||
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||
.card-glyph{
|
||||
font-size:1.1rem;width:28px;height:28px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:var(--surface2);border-radius:6px;flex-shrink:0;
|
||||
}
|
||||
.card-type{
|
||||
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
|
||||
color:var(--text3);
|
||||
}
|
||||
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
|
||||
|
||||
.card-title{
|
||||
font-size:0.95rem;font-weight:600;color:var(--text);
|
||||
line-height:1.4;margin-bottom:6px;
|
||||
}
|
||||
.card-body{
|
||||
font-size:0.85rem;color:var(--text2);line-height:1.55;
|
||||
white-space:pre-wrap;word-break:break-word;
|
||||
}
|
||||
|
||||
.card-body code{
|
||||
font-family:var(--mono);font-size:0.8rem;
|
||||
background:var(--surface2);padding:2px 5px;border-radius:4px;
|
||||
}
|
||||
|
||||
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
||||
.tag{
|
||||
font-size:0.7rem;color:var(--accent);background:var(--surface2);
|
||||
padding:3px 8px;border-radius:6px;
|
||||
}
|
||||
|
||||
/* snippet */
|
||||
.snippet-block{
|
||||
background:var(--bg);border:1px solid var(--border);border-radius:8px;
|
||||
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
|
||||
}
|
||||
.snippet-block pre{
|
||||
font-family:var(--mono);font-size:0.8rem;color:var(--text);
|
||||
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
|
||||
}
|
||||
.copy-btn{
|
||||
position:absolute;top:8px;right:8px;
|
||||
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--surface);color:var(--text2);font-size:0.7rem;
|
||||
cursor:pointer;transition:all 0.15s;
|
||||
}
|
||||
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
|
||||
|
||||
/* checklist */
|
||||
.checklist{margin-top:8px;list-style:none}
|
||||
.checklist li{
|
||||
display:flex;align-items:flex-start;gap:10px;
|
||||
padding:8px 0;border-bottom:1px solid var(--border);
|
||||
font-size:0.85rem;color:var(--text2);
|
||||
}
|
||||
.checklist li:last-child{border-bottom:none}
|
||||
.check-box{
|
||||
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
|
||||
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||
transition:all 0.15s;margin-top:1px;
|
||||
}
|
||||
.check-box.checked{background:var(--green);border-color:var(--green)}
|
||||
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
|
||||
.check-text.done{text-decoration:line-through;color:var(--text3)}
|
||||
.progress-bar{
|
||||
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
|
||||
}
|
||||
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
|
||||
|
||||
/* template */
|
||||
.template-field{margin-top:8px}
|
||||
.template-field label{
|
||||
display:block;font-size:0.7rem;font-weight:600;
|
||||
text-transform:uppercase;letter-spacing:0.05em;
|
||||
color:var(--text3);margin-bottom:4px;
|
||||
}
|
||||
.template-field input{
|
||||
width:100%;padding:8px 12px;border-radius:8px;
|
||||
border:1px solid var(--border);background:var(--surface2);
|
||||
color:var(--text);font-size:0.85rem;font-family:var(--sans);
|
||||
outline:none;transition:border-color 0.15s;
|
||||
}
|
||||
.template-field input:focus{border-color:var(--accent)}
|
||||
.template-output{margin-top:10px}
|
||||
.template-copy-btn{
|
||||
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
|
||||
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
|
||||
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
|
||||
}
|
||||
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
|
||||
|
||||
/* decision */
|
||||
.decision-grid{margin-top:8px}
|
||||
.decision-row{
|
||||
padding:8px 0;border-bottom:1px solid var(--border);
|
||||
}
|
||||
.decision-row:last-child{border-bottom:none}
|
||||
.decision-label{
|
||||
font-size:0.7rem;font-weight:600;text-transform:uppercase;
|
||||
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
|
||||
}
|
||||
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
|
||||
.decision-rejected{color:var(--red);font-style:italic}
|
||||
|
||||
/* link */
|
||||
.link-target{
|
||||
display:flex;align-items:center;gap:10px;margin-top:8px;
|
||||
padding:12px;background:var(--bg);border:1px solid var(--border);
|
||||
border-radius:8px;text-decoration:none;color:var(--text);
|
||||
transition:border-color 0.15s;
|
||||
}
|
||||
.link-target:active{border-color:var(--accent)}
|
||||
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
|
||||
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
|
||||
|
||||
.empty-state{
|
||||
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
|
||||
}
|
||||
|
||||
@media(min-width:640px){
|
||||
.card-list{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
|
||||
gap:10px;padding:8px 20px;
|
||||
}
|
||||
.card{margin-bottom:0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="deck-header">
|
||||
<div class="deck-title">{{.Title}}</div>
|
||||
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<input type="text" class="search-input" placeholder="search cards…" id="search">
|
||||
</div>
|
||||
|
||||
<div class="filter-bar" id="filters">
|
||||
<button class="filter-chip active" data-type="all">All</button>
|
||||
{{range .Types}}
|
||||
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-list" id="cards">
|
||||
{{range .Cards}}
|
||||
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
|
||||
<div class="card-top">
|
||||
<span class="card-glyph">{{.Glyph}}</span>
|
||||
<span class="card-type">{{.CardType}}</span>
|
||||
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
|
||||
</div>
|
||||
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
|
||||
|
||||
{{if eq .CardType "snippet"}}
|
||||
<div class="snippet-block">
|
||||
<button class="copy-btn" onclick="copyText(this)">copy</button>
|
||||
<pre>{{.Body}}</pre>
|
||||
</div>
|
||||
{{else if eq .CardType "checklist"}}
|
||||
<div class="card-body">{{.Description}}</div>
|
||||
<ul class="checklist" data-card-id="{{.ID}}">
|
||||
{{range $i, $step := .Steps}}
|
||||
<li>
|
||||
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
|
||||
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
|
||||
{{else if eq .CardType "template"}}
|
||||
<div class="card-body">{{.Description}}</div>
|
||||
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
|
||||
{{range .Slots}}
|
||||
<div class="template-field">
|
||||
<label>{{.Name}}</label>
|
||||
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="template-output">
|
||||
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else if eq .CardType "decision"}}
|
||||
<div class="decision-grid">
|
||||
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
|
||||
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
|
||||
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
|
||||
</div>
|
||||
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
|
||||
{{else if eq .CardType "link"}}
|
||||
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
|
||||
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
|
||||
<span class="link-url">{{.LinkURL}}</span>
|
||||
<span class="link-arrow">↗</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="card-body">{{.Body}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Tags}}
|
||||
<div class="card-tags">
|
||||
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const cards=document.querySelectorAll('.card');
|
||||
const chips=document.querySelectorAll('.filter-chip');
|
||||
const search=document.getElementById('search');
|
||||
const empty=document.getElementById('empty');
|
||||
let activeType='all';
|
||||
|
||||
function applyFilters(){
|
||||
const q=search.value.toLowerCase().trim();
|
||||
let visible=0;
|
||||
cards.forEach(c=>{
|
||||
const typeMatch=activeType==='all'||c.dataset.type===activeType;
|
||||
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
|
||||
c.style.display=(typeMatch&&searchMatch)?'':'none';
|
||||
if(typeMatch&&searchMatch)visible++;
|
||||
});
|
||||
empty.style.display=visible?'none':'block';
|
||||
}
|
||||
|
||||
chips.forEach(chip=>{
|
||||
chip.addEventListener('click',()=>{
|
||||
chips.forEach(c=>c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
activeType=chip.dataset.type;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
search.addEventListener('input',applyFilters);
|
||||
})();
|
||||
|
||||
function copyText(btn){
|
||||
const pre=btn.parentElement.querySelector('pre');
|
||||
navigator.clipboard.writeText(pre.textContent).then(()=>{
|
||||
btn.textContent='copied!';
|
||||
setTimeout(()=>btn.textContent='copy',1500);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCheck(box){
|
||||
box.classList.toggle('checked');
|
||||
const span=box.nextElementSibling;
|
||||
span.classList.toggle('done');
|
||||
const list=box.closest('.checklist');
|
||||
const boxes=list.querySelectorAll('.check-box');
|
||||
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
|
||||
const bar=list.nextElementSibling.querySelector('.progress-fill');
|
||||
bar.style.width=Math.round((done/boxes.length)*100)+'%';
|
||||
}
|
||||
|
||||
function updateTemplate(input){
|
||||
const form=input.closest('.template-form');
|
||||
const tmpl=form.dataset.template;
|
||||
let filled=tmpl;
|
||||
form.querySelectorAll('input[data-slot]').forEach(inp=>{
|
||||
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
|
||||
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
|
||||
});
|
||||
form._filled=filled;
|
||||
}
|
||||
|
||||
function copyTemplate(btn){
|
||||
const form=btn.closest('.template-form');
|
||||
const text=form._filled||form.dataset.template;
|
||||
navigator.clipboard.writeText(text).then(()=>{
|
||||
btn.textContent='copied!';
|
||||
setTimeout(()=>btn.textContent='copy filled template',1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user