Merge pull request 'feat(export): add HTML card deck export' (#41) from feat/html-card-export into main
CI / test (push) Failing after 17s
CI / test (push) Failing after 17s
This commit was merged in pull request #41.
This commit is contained in:
+60
-6
@@ -6,19 +6,28 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/export"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var exportOutput string
|
var (
|
||||||
|
exportOutput string
|
||||||
|
exportFormat string
|
||||||
|
exportTag string
|
||||||
|
exportTitle string
|
||||||
|
)
|
||||||
|
|
||||||
var exportCmd = &cobra.Command{
|
var exportCmd = &cobra.Command{
|
||||||
Use: "export",
|
Use: "export",
|
||||||
Short: "dump all entities to JSON",
|
Short: "export entities to JSON or HTML card deck",
|
||||||
RunE: runExport,
|
RunE: runExport,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout")
|
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)
|
rootCmd.AddCommand(exportCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +57,58 @@ func runExport(cmd *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
ctx := cmd.Context()
|
|
||||||
|
|
||||||
p := db.DefaultListParams()
|
p := db.DefaultListParams()
|
||||||
p.IncludeDeleted = true
|
|
||||||
p.Limit = 10000
|
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 {
|
if err != nil {
|
||||||
return err
|
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