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:
+60
-6
@@ -6,19 +6,28 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/export"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var exportOutput string
|
||||
var (
|
||||
exportOutput string
|
||||
exportFormat string
|
||||
exportTag string
|
||||
exportTitle string
|
||||
)
|
||||
|
||||
var exportCmd = &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "dump all entities to JSON",
|
||||
Short: "export entities to JSON or HTML card deck",
|
||||
RunE: runExport,
|
||||
}
|
||||
|
||||
func init() {
|
||||
exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout")
|
||||
exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "json", "output format: json or html")
|
||||
exportCmd.Flags().StringVarP(&exportTag, "tag", "t", "", "filter by tag (used as deck name for HTML)")
|
||||
exportCmd.Flags().StringVar(&exportTitle, "title", "", "deck title for HTML export")
|
||||
rootCmd.AddCommand(exportCmd)
|
||||
}
|
||||
|
||||
@@ -48,13 +57,58 @@ func runExport(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := cmd.Context()
|
||||
|
||||
p := db.DefaultListParams()
|
||||
p.IncludeDeleted = true
|
||||
p.Limit = 10000
|
||||
|
||||
entities, err := store.List(ctx, p)
|
||||
if exportTag != "" {
|
||||
p.Tag = &exportTag
|
||||
}
|
||||
|
||||
switch exportFormat {
|
||||
case "html":
|
||||
return runHTMLExport(cmd, store, p)
|
||||
case "json":
|
||||
p.IncludeDeleted = true
|
||||
return runJSONExport(cmd, store, p)
|
||||
default:
|
||||
return fmt.Errorf("unknown format %q (use json or html)", exportFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func runHTMLExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
|
||||
p.CardsOnly = true
|
||||
|
||||
entities, err := store.List(cmd.Context(), p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := exportTitle
|
||||
if title == "" && exportTag != "" {
|
||||
title = "#" + exportTag
|
||||
}
|
||||
if title == "" {
|
||||
title = "nib cards"
|
||||
}
|
||||
|
||||
if exportOutput != "" {
|
||||
f, err := os.Create(exportOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := export.RenderHTML(f, entities, title); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d cards to %s\n", len(entities), exportOutput)
|
||||
return nil
|
||||
}
|
||||
|
||||
return export.RenderHTML(cmd.OutOrStdout(), entities, title)
|
||||
}
|
||||
|
||||
func runJSONExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
|
||||
entities, err := store.List(cmd.Context(), p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
"github.com/lerko/nib/internal/display"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var templateHTML string
|
||||
|
||||
type TemplateSlot struct {
|
||||
Name string `json:"name"`
|
||||
Default string `json:"default"`
|
||||
}
|
||||
|
||||
type CheckStep struct {
|
||||
Text string `json:"text"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
type DecisionData struct {
|
||||
Chose string `json:"chose"`
|
||||
Why string `json:"why"`
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
type CardView struct {
|
||||
ID string
|
||||
Glyph string
|
||||
CardType string
|
||||
Title string
|
||||
Body template.HTML
|
||||
Description template.HTML
|
||||
SearchText string
|
||||
Pinned bool
|
||||
Tags []string
|
||||
UseCount int
|
||||
LinkURL string
|
||||
Slots []TemplateSlot
|
||||
TemplateBody string
|
||||
Steps []CheckStep
|
||||
Progress int
|
||||
Decision DecisionData
|
||||
}
|
||||
|
||||
type DeckView struct {
|
||||
Title string
|
||||
Count int
|
||||
ExportedAt string
|
||||
Types []string
|
||||
Cards []CardView
|
||||
}
|
||||
|
||||
func RenderHTML(w io.Writer, entities []*db.Entity, title string) error {
|
||||
tmpl, err := template.New("deck").Parse(templateHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var types []string
|
||||
var cards []CardView
|
||||
|
||||
for _, e := range entities {
|
||||
if e.CardType == nil {
|
||||
continue
|
||||
}
|
||||
ct := string(*e.CardType)
|
||||
if !seen[ct] {
|
||||
seen[ct] = true
|
||||
types = append(types, ct)
|
||||
}
|
||||
cards = append(cards, buildCardView(e))
|
||||
}
|
||||
|
||||
data := DeckView{
|
||||
Title: title,
|
||||
Count: len(cards),
|
||||
ExportedAt: time.Now().Format("Jan 2, 2006"),
|
||||
Types: types,
|
||||
Cards: cards,
|
||||
}
|
||||
|
||||
return tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func buildCardView(e *db.Entity) CardView {
|
||||
ct := ""
|
||||
if e.CardType != nil {
|
||||
ct = string(*e.CardType)
|
||||
}
|
||||
|
||||
title := ""
|
||||
if e.Title != nil {
|
||||
title = *e.Title
|
||||
}
|
||||
|
||||
body := e.Body
|
||||
desc := ""
|
||||
if e.Description != nil {
|
||||
desc = *e.Description
|
||||
}
|
||||
|
||||
search := strings.ToLower(title + " " + body + " " + desc + " " + strings.Join(e.Tags, " "))
|
||||
|
||||
cv := CardView{
|
||||
ID: e.ID,
|
||||
Glyph: display.DisplayGlyph(e.Glyph, e.CardType),
|
||||
CardType: ct,
|
||||
Title: title,
|
||||
Body: template.HTML(template.HTMLEscapeString(body)),
|
||||
SearchText: search,
|
||||
Pinned: e.Pinned,
|
||||
Tags: e.Tags,
|
||||
UseCount: e.UseCount,
|
||||
}
|
||||
|
||||
if desc != "" {
|
||||
cv.Description = template.HTML(template.HTMLEscapeString(desc))
|
||||
}
|
||||
|
||||
if e.CardData != nil {
|
||||
parseCardData(&cv, ct, *e.CardData, body)
|
||||
}
|
||||
|
||||
return cv
|
||||
}
|
||||
|
||||
func parseCardData(cv *CardView, ct string, raw string, body string) {
|
||||
switch ct {
|
||||
case "snippet":
|
||||
// body is the snippet content, already set
|
||||
|
||||
case "template":
|
||||
var data struct {
|
||||
Slots []TemplateSlot `json:"slots"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||
cv.Slots = data.Slots
|
||||
}
|
||||
cv.TemplateBody = body
|
||||
|
||||
case "checklist":
|
||||
var data struct {
|
||||
Steps []CheckStep `json:"steps"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||
cv.Steps = data.Steps
|
||||
done := 0
|
||||
for _, s := range cv.Steps {
|
||||
if s.Done {
|
||||
done++
|
||||
}
|
||||
}
|
||||
if len(cv.Steps) > 0 {
|
||||
cv.Progress = (done * 100) / len(cv.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
case "decision":
|
||||
var dec DecisionData
|
||||
if json.Unmarshal([]byte(raw), &dec) == nil {
|
||||
cv.Decision = dec
|
||||
}
|
||||
|
||||
case "link":
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &data) == nil {
|
||||
cv.LinkURL = data.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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